codetocanvas
v0.1.0
Published
See your React prototype whole: every screen, feature and iteration on one zoomable board, live and joined by user-flow connectors.
Downloads
288
Maintainers
Readme
codetocanvas
See your React prototype whole. Every screen, every feature, every iteration on one zoomable board, laid out as a user flow and joined by arrows labelled with what the user clicks. Not screenshots and not an export: your real components, running live, side by side.
<FlowCanvas />That one component adds a Prototype / Canvas toggle — build in one, review the whole thing in the other. With no configuration it records screens at runtime as you click through. Flow files turn the board into a curated design source of truth: a section per feature, a lane per iteration (v1, v2…), in any of your prototype's interface languages.
Scope
The job is laying your prototype out as a flow. Two things it deliberately doesn't do yet:
- In-progress screen states aren't supported. There's no way to mark a screen as a stub, half-built or work in progress — every screen on the board reads as equally finished. A flow describes what exists, not how far along it is.
- Nothing is generated at build time. The board comes from runtime capture, or from flow files — written for you by
/map-flow, or by hand. No routes are parsed and no map is produced by a build.
Transient states — loading, errors, mid-process — are shown by rendering your component in that state, with props you supply. That's ordinary code in your own flow file; the plugin has no fixture system of its own. How much of your prototype the board can reach depends on what your components let you set from outside: see Letting the canvas open a state.
Install
1. Add the package
npm install codetocanvas# or
pnpm add codetocanvas
yarn add codetocanvas
bun add codetocanvasIt needs React 18 or newer (react and react-dom). There's no stylesheet to import; the styles come bundled.
2. Drop it next to your app
Render it once, anywhere, usually beside your app's root. It draws its own floating toggle and full-screen canvas, so it doesn't matter where it sits in your tree.
Vite
// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { FlowCanvas } from 'codetocanvas'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
{import.meta.env.DEV && <FlowCanvas />}
</StrictMode>,
)Next.js (App Router)
// app/layout.tsx
import { FlowCanvas } from 'codetocanvas' // already marked 'use client'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
{process.env.NODE_ENV === 'development' && <FlowCanvas />}
</body>
</html>
)
}Create React App / Webpack
{process.env.NODE_ENV === 'development' && <FlowCanvas />}The DEV / development check keeps it out of production builds. Leave it off if you want the toggle on a shared demo link too.
Backing out. That check is also the answer to "how do I remove this?" — a production build drops the component and everything it renders, so nothing reaches your users either way. To detach properly, remove the <FlowCanvas /> line and its import, then the flows import, since flow files import defineFlow and would keep the dependency alive. What's left in your own code is plain React with nothing to unpick: the initial* props that let a screen open at a given state, and any data-flow-screen attributes. Both are worth keeping regardless.
3. Fill the board
Two ways. Take the first one — it reads your code, so it finds screens you'd have to hunt for by hand, including the states you can't just click into (loading, errors, mid-process).
Map it from your code (recommended)
npx codetocanvas install-skill # copies /map-flow into .claude/skills/Then run /map-flow in Claude Code. It reads your prototype, proposes the features and iterations it found, and on your say-so writes flows/<feature>/<iteration>.flow.tsx plus flows/index.ts. Pass them to the canvas and that's your board:
<FlowCanvas flows={flows} />Installing the package doesn't install the skill — a dependency shouldn't write into your .claude/ folder uninvited — so it's this one extra command. See Keeping flows in sync for updating flows as the code changes.
Or click through it yourself
With no flows prop, the canvas records as you go. Useful for a first look, or when you'd rather not run a skill:
- Click through your prototype. Each screen is captured as you reach it.
- Click Canvas in the bottom-left corner.
- Every screen is laid out left to right in the order you reached them. Branches stack below, and the arrows are labelled with the button or link you clicked ("Continue", "Start call"…).
Captures live in localStorage and Clear starts them over. They're a sketch, not a source of truth: they only cover what you clicked, and they don't survive a refactor. Flow files are the ones you keep.
Either way: drag the background to pan, ⌘/Ctrl-scroll or pinch to zoom towards the pointer, −/+/Fit in the corner. Adding #canvas to the URL opens the canvas directly. Your app stays mounted underneath, so switching back loses no state.
Naming screens (optional)
By default, each URL counts as one screen. That's right for multi-page apps and router-based SPAs.
If your screens aren't separate URLs, like steps in a modal, tabs or a wizard, name them with a data-flow-screen attribute on each screen's outer element:
<div className="modal" data-flow-screen={`Step ${step}`}>…</div>
<main data-flow-screen="Agents page">…</main>- Each distinct name becomes its own screen on the canvas. You can include state in the name, e.g.
"Checkout · error". - When several named screens are visible, like a modal over a page, both are captured. The one later in the page counts as the screen in front, for the arrows.
Flows: features and iterations
A flow is one iteration of a feature: a set of screens joined by arrows. Keep one file per iteration, and list them all in a plain flows/index.ts. It's an ordinary array, so it works with any bundler.
// flows/onboarding/v2.flow.tsx
import { defineFlow } from 'codetocanvas'
export default defineFlow({
feature: 'Onboarding',
iteration: 'v2 · Genie chat',
notes: 'Type a sentence on Home, then Genie sets the agent up in a chat',
source: 'src/agent/SetupChat.tsx',
languages: ['en', 'hi'], // interface locales, optional
screens: [
{ id: 'home', label: 'Home', render: () => <Home /> },
{ id: 'type', label: 'Confirm the type', width: 1280, height: 780, render: () => <AgentPage step="type" /> },
{ id: 'type-error', label: 'Nothing typed', row: 1, render: () => <AgentPage step="type" error /> },
{ id: 'voice', label: 'Pick a voice', render: () => <AgentPage step="voice" /> },
],
edges: [
{ from: 'home', to: 'type', label: 'Enter' },
{ from: 'type', to: 'type-error', dashed: true },
{ from: 'type', to: 'voice', label: 'Looks right' },
],
})// flows/index.ts
import onboardingV1 from './onboarding/v1.flow'
import onboardingV2 from './onboarding/v2.flow'
import settings from './settings/current.flow'
export const flows = [onboardingV1, onboardingV2, settings]<FlowCanvas flows={flows} />- Sections and lanes. Flows with the same
featureshare a section titled with it, one lane periteration. The lane header shows the iteration's name, notes, source and languages. - Layout. Each lane runs left to right in the order you list its screens.
row: 1(or2…) stacks a screen under the one before it, for a branch or an error state.colpins a screen to a column. - Screen ids line up across iterations. Give the same step the same id in every iteration (
welcome,pick-type…) and it sits in the same column in each lane, so v1 and v2 compare side by side. - Three kinds of screen:
render: (ctx) => <Screen />: live React. The default size is 520 × 600.image: '/shots/welcome.png': a screenshot or reference, e.g. the shipped version. It shows at full height by default.url: 'https://my-prototype.app/welcome': an iframe, e.g. a deployed prototype.
height: 'auto'lets a screen grow with its content instead of clipping at a fixed height.sourceslists the files and folders a flow was written from, andfingerprintis their hash (npx codetocanvas fingerprint). When they change,npx codetocanvas check --marksetsstale: trueand the lane shows "May be out of date". See Keeping flows in sync.- Toolbar. The board's title and screen count sit top left. The controls sit top right, and stack on narrow screens: a feature filter (when there's more than one), the language menu, a Motion / Paused switch, and zoom. The board remembers these per browser.
Components you render in flows often need app state (the current user, a draft…). <FlowCanvas /> renders through a portal, so React context still reaches the screens: wrap it in your providers and read them from the screen components, which keeps the flow files static.
Letting the canvas open a state
The board can only show states your app lets something else set. A screen is a component rendered with props — so if a state exists only as useState inside a component, with no way in, the canvas can render that component's default and nothing more. This is the one thing that decides how much of your prototype the board can reach, so it's worth designing for.
The fix is plain React: optional props for the starting state, each defaulting to what happens today.
export default function App({ initialTasks = SEED, initialFilter = 'all', initialUser = null, initialOpenId = null }: Props = {}) {
const [tasks, setTasks] = useState(initialTasks)
const [filter, setFilter] = useState(initialFilter)
// …
}<App /> behaves exactly as before, and each screen opens a state directly:
{ id: 'tasks-done', label: 'Filtered to done', render: () => <App initialFilter="done" /> },
{ id: 'tasks-empty', label: 'No tasks', render: () => <App initialTasks={[]} /> },
{ id: 'task-detail', label: 'Task detail', render: () => <App initialOpenId="t1" /> },No plugin API and nothing to couple to — it's a component that accepts its own starting state, which is worth doing for tests and Storybook anyway. Three other routes to the same place:
- Render the inner component instead of the root. A modal or panel that already takes props (
<TaskModal task={…} />) needs nothing new. - State that's persisted — a
useStoredState-style hook — can read a snapshot instead: seeuseCanvasState(). - A state that can't be reached at all (mid-call, a failed request) needs a fixture: a frozen object satisfying the same type, with no-op functions.
Rendering a screen from saved state
When a prototype keeps its state in a storage hook (so a reload resumes where you left off), a screen can carry that state instead of props. Give the screen a state object and have your hook read it through useCanvasState():
import { useCanvasState } from 'codetocanvas'
export function useStoredState<T>(key: string, initial: T) {
// Inside a frame this is the screen's `state`; outside the canvas it's null.
const snapshot = useCanvasState()
const [value, setValue] = useState<T>(() => {
if (snapshot) return key in snapshot ? (snapshot[key] as T) : initial
return readFromStorage(key, initial)
})
useEffect(() => {
if (snapshot) return // a frame must never write to the app's own storage
writeToStorage(key, value)
}, [snapshot, key, value])
return [value, setValue] as const
}Then every screen is the same component at a different point in the flow:
{ id: 'step-2', label: 'Pick a voice', state: { 'app:step': 2, 'app:modal': true }, render: () => <App /> },Two things to watch: render the root and it will render <FlowCanvas /> too, so guard that with useFlowCanvas().inCanvas; and a flow like this depends on your state keys, so it goes stale when those change rather than when a component's props do.
Keeping side effects out of the canvas
Every screen on the board is mounted at once, so a screen that plays audio, calls an API or pre-fetches on mount would do it many times over. useFlowCanvas() tells a component it's inside a canvas frame:
import { useFlowCanvas } from 'codetocanvas'
function VoicePreview({ text }) {
const { inCanvas, locale, paused } = useFlowCanvas()
useEffect(() => {
if (inCanvas) return // don't spend credits just by being on the board
prefetchAudio(text)
}, [inCanvas, text])
// …
}It returns { inCanvas, locale, dir, paused }. Outside the canvas it's { inCanvas: false, locale: undefined, dir: 'ltr', paused: false }. The same object is passed to render(ctx).
pausedfollows the toolbar's Motion / Paused switch. CSS animations and transitions inside screens are frozen automatically; usepausedfor JS-driven motion and video.- Only screens near the viewport are mounted. Screens scrolled far away show a light placeholder and unmount, which keeps big boards light. (A screen's local state resets when it unmounts.)
Multilingual interface support
The language menu switches the interface language of the screens: your app's i18n locale, for every lane at once.
1. Declare the locales each iteration supports. The first one is its fallback.
defineFlow({ feature: 'Checkout', iteration: 'v2', languages: ['en', 'hi', 'ar'], … })The menu lists every language any flow declares. Lanes show their languages, highlighting the current one. A lane that doesn't support the current locale says so ("Not in Arabic · showing English") and shows its fallback.
2. Wire your i18n library. React screens get the locale through render(ctx) and useFlowCanvas(). To apply it, pass a LocaleProvider that wraps each React screen:
// react-i18next: a clone of your instance per locale, so lanes can differ without switching the app
import { I18nextProvider } from 'react-i18next'
import { FlowCanvas, i18nextLocaleProvider } from 'codetocanvas'
import i18n from './i18n'
const CanvasLocale = i18nextLocaleProvider(i18n, I18nextProvider) // once, outside components
<FlowCanvas flows={flows} LocaleProvider={CanvasLocale} />// next-intl, lingui, react-intl or your own context: map the locale onto the library's provider
<FlowCanvas
flows={flows}
LocaleProvider={({ locale, children }) => <IntlProvider locale={locale} messages={messages[locale]}>{children}</IntlProvider>}
/>Define LocaleProvider outside the component (or memoise it), so the screens don't remount on every render.
3. The other screen kinds.
urlscreens get the locale as a query param,?lang=hiby default. SetlocaleParamto rename it,urlLocale="path"for a/hi/…prefix,urlLocale="none"to leave URLs alone, or pass a function(url, locale) => string. A screen can override it with its ownurlLocale.imagescreens can't switch language. Give one image per locale (image: { en: '/en/welcome.png', hi: '/hi/welcome.png' }); where one is missing, the screen shows its first image with a "Not in Hindi" badge.- Right-to-left locales (Arabic, Hebrew, Urdu, Persian…, or any locale in an RTL script) get
dir="rtl"on their screens, and every screen getslang. Useheight: 'auto'where longer translations need room.
defaultLocale picks the starting language; after that, the board remembers the last one chosen.
Keeping flows in sync: /map-flow
Writing flow files by hand works, but the package ships a Claude Code skill that does it for you, and a small CLI that keeps them honest.
npx codetocanvas install-skill # copies the skill to .claude/skills/map-flowThen, in Claude Code:
/map-flowreads your prototype, finds its features and their iterations (version folders, version switches, parallel components, a hand-made canvas), and proposes flow files. You confirm, it writesflows/<feature>/<iteration>.flow.tsxandflows/index.ts, with fixtures for states you can't just click into (loading, errors, mid-process). It never silently changes an iteration's notes./map-flow --checkfinds flows whose code changed and proposes updates./map-flow --import <screenshots or URLs>adds an iteration that isn't in the code, such as the shipped product's screenshots.
The CLI is the deterministic part (no AI, no network), and runs fine on its own:
| Command | |
|---|---|
| codetocanvas check [flows-dir] [--mark] | Lists flows whose sources changed since their fingerprint, or are missing. Exits 1 if any are stale, for CI. --mark writes stale: true into them (and removes it from fresh ones), so the canvas shows the warning. |
| codetocanvas fingerprint [paths…] | Records the current hash of each flow's sources and clears stale. |
| codetocanvas index [flows-dir] | Syncs flows/index.ts with the flow files on disk. It keeps the current import order (the board order) and appends new flows. |
| codetocanvas install-skill [--force] | Copies the /map-flow skill into .claude/skills/map-flow. |
The flows folder defaults to src/flows, then flows. Run the commands from the project root: sources paths are relative to it. A handy setup:
// package.json
"scripts": {
"predev": "codetocanvas check --mark || true" // flag stale flows on dev start, without blocking it
}Keep sources a plain array of string literals: the CLI reads flow files as text. Leave fingerprint and stale to the CLI.
Placing screens by hand (optional)
For a single flow you can also pass frames and edges, placing each screen on a grid yourself. This is the simpler, older form of a flow. Frames render live React, so they stay interactive. lanes adds headings: { title, subtitle?, row }, with that lane's frames on the rows below it. languages declares their interface locales.
import { FlowCanvas, type FlowEdge, type FlowFrame } from 'codetocanvas'
const frames: FlowFrame[] = [
{ id: 'welcome', label: 'Welcome', col: 0, row: 0, render: () => <Welcome /> },
{ id: 'signup', label: 'Sign up', col: 1, row: 0, render: () => <SignUp /> },
{ id: 'sso', label: 'Sign in with SSO', col: 1, row: 1, render: () => <Sso /> },
{ id: 'home', label: 'Home', col: 2, row: 0, width: 1100, height: 700, render: () => <Home /> },
]
const edges: FlowEdge[] = [
{ from: 'welcome', to: 'signup', label: 'Get started' },
{ from: 'signup', to: 'sso', label: 'Use SSO', dashed: true },
{ from: 'signup', to: 'home', label: 'Create account' },
{ from: 'sso', to: 'home' },
]
<FlowCanvas title="Onboarding" frames={frames} edges={edges} />API
<FlowCanvas />
| Prop | Default | |
|---|---|---|
| flows | | Features and their iterations: an array of defineFlow() objects. |
| frames | | Screens placed by hand, as one flow. Leave out flows and frames to capture screens automatically. |
| edges | | The arrows between frames: { from, to, label?, dashed? }. |
| lanes | | Headings between frames: { title, subtitle?, row }. |
| languages | | Interface locales of the frames. |
| defaultLocale | first declared | The language the board starts in. |
| LocaleProvider | | ({ locale, dir, children }) => …: applies the board's locale to React screens. See i18nextLocaleProvider(). |
| urlLocale | 'query' | How url screens get the locale: 'query', 'path', 'none' or (url, locale) => string. |
| localeParam | 'lang' | The query param for urlLocale: 'query'. |
| title | document.title | Shown in the canvas toolbar. |
| gap | 150 | The space between screens in px (room for the arrows and their labels). |
| storageKey | 'flow-canvas' | Prefix for the saved view, toolbar settings and captured screens in localStorage. Set a distinct one per prototype if several share an origin. |
| onModeChange | | (mode: 'prototype' \| 'canvas') => void. Use it to pause audio, video or timers in the app when the canvas opens. |
A screen is { id, label?, col?, row?, width?, height?, … } plus one of render, image or url. A FlowFrame is { id, label, col, row, width?, height?, render }. The default size is 520 × 600.
Also exported: defineFlow(), useFlowCanvas(), useCanvasState(), i18nextLocaleProvider(), and the helpers localizeUrl() and direction().
Lower-level pieces
For custom setups, these are exported too: FlowBoard (the board on its own), ViewToggle and useViewMode() (the switch and its state), and useFlowCapture() and layoutCapture() (the recorder and its layout).
How capture works
It's deterministic, with no AI and no network calls:
- A
MutationObserverwatches the page. Once it settles for about 0.4s, the plugin takes a static copy of each visible screen: its HTML, with current form values written in. Screens are keyed bydata-flow-screen, or by the URL if there's no marker. - When the screen in front changes, it records an arrow from the previous screen, labelled with the button or link clicked in the last few seconds.
- The layout is breadth-first from the first screen you captured: each screen goes one column after the earliest screen that leads to it, and branches stack below.
- Captures are kept per interface locale, read from
<html lang>. Capture in two languages and the canvas shows one lane per language. - Everything is stored in
localStoragein your browser. Clear wipes it.
Captured screens are static copies: they show what was on screen, styled by your app's CSS, but you can't click them. Style-only changes, like animations, don't trigger new captures.
Developing
npm install
npm run dev # rebuilds dist/ on change
npm run typecheck
npm run buildTo try it in a local app before publishing, run npm install ../flow-canvas (this folder) from that app. If the app uses Vite, also set resolve: { dedupe: ['react', 'react-dom'] }, so the app and this folder's dev copy of React don't both load.
Publishing
npm login
npm version patch # or minor / major
npm publish # runs typecheck + build firstOnly dist/, skills/, this README and package.json are published. Check with npm pack --dry-run.
