Getting started
A live canvas for one component in four steps. Declare your groups in a config, write a Button.windo.tsx next to the component it previews, point the dev server at it, then edit props as JSON and snapshot a variant. windo owns the iframe, the controls, and the validation — you describe the component once.
Here's the destination. A Button.windo.tsx that renders inside an isolated preview iframe, exposes its props as a JSON edit surface validated by zod, and ships a handful of one-click variants. Everything below builds up to that file.
1. Configure your groups
defineWindoConfig is the single entry point. You give it the groups that become your sidebar sections, and it hands back two things: a config object the tooling reads, and a windo factory whose group field is type-checked against the slugs you just declared. Every *.windo.tsx imports that factory, so misnaming a group is a type error, not a runtime surprise.
import { defineWindoConfig } from '@westopp/windo' export const { config, windo } = defineWindoConfig({ title: 'Acme UI', groups: [ { name: 'Primitives', slug: 'primitives' }, { name: 'Forms', slug: 'forms', description: 'Inputs and controls' }, ], })
Export both config and windo. The dev server reads config; your windo files import windo.
2. Write a windo
A windo lives beside the component it documents — Button.windo.tsx next to Button.tsx — and default-exports the result of the windo() factory. Four fields carry the work:
titleandgroupplace it in the sidebar.component: (props, ctx) => JSXrenders the real component with whatever props are live.defaultPropsis the starting state — full props, including functions and JSX that JSON can't express.configurablePropsis the JSON edit surface: a zod schema, bound to the prop type throughconfigurableProps<Props>()(z.object(...)). Itsz.inputis what you type in the Controls panel; itsz.outputis what reaches the component, transforms and defaults applied.
import { z } from 'zod' import { configurableProps } from '@westopp/windo' import { windo } from './windo.config' import { Button, type ButtonProps } from './Button' const props = configurableProps<ButtonProps>()( z.object({ label: z.string().default('Click me'), variant: z.enum(['primary', 'secondary', 'ghost']).default('primary'), disabled: z.boolean().default(false), }) ) export default windo<ButtonProps>(w => ({ title: 'Button', group: w.groups.primitives.slug, configurableProps: props, defaultProps: { label: 'Click me', variant: 'primary', disabled: false }, component: (props, ctx) => <Button {...props} onClick={() => ctx.logger.log('click')} />, }))
3. Run it
Two ways in. The CLI needs no config wiring — point windo dev at a directory and it serves the workbench, discovering every *.windo.tsx under it.
windo dev . # serve the cwd windo dev ./src -p 6006 --open
If you already run Vite, add the plugin instead. It pairs windo with @vitejs/plugin-react, serves the chrome and iframe HTML, and finds your windo files for you.
import { defineConfig } from 'vite' import { windo } from '@westopp/windo/plugin' export default defineConfig({ plugins: [windo({ config: 'windo.config.ts' })], })
Either path lands you on the same workbench: the sidebar grouped by your config, your Button selected, rendered inside its own iframe.
4. Edit props, then capture a variant
Open the Controls panel and edit the props as JSON. The schema from step 2 validates every keystroke inside the iframe — coercions, defaults, and refinements run live — so a bad value surfaces a per-field error instead of crashing the preview. Anything you leave out falls back to defaultProps.
Once you've found a state worth keeping, promote it to a variant: a labelled prop patch that shows up as a one-click chip in the gallery. Variants are partial — list only the props that differ from the defaults.
export default windo<ButtonProps>(w => ({ title: 'Button', group: w.groups.primitives.slug, configurableProps: props, defaultProps: { label: 'Click me', variant: 'primary', disabled: false }, variants: [ { label: 'Secondary', props: { variant: 'secondary', label: 'Secondary' } }, { label: 'Disabled', props: { disabled: true } }, ], component: (props, ctx) => <Button {...props} onClick={() => ctx.logger.log('click')} />, }))
Clicking Secondary applies its patch over the current props; clicking Disabled flips one boolean. That's the whole loop — declare once, then explore live.
Where next
- How it works — the iframe boundary, why props travel as JSON, and where the schema actually runs.
- Configurable props — the full
configurablePropscontract:z.inputvsz.output, error surfacing, and the merge withdefaultProps. - Variants — composing prop patches and what a variant can and can't carry.