windo/Guides/Writing a windo
Guides~8 min

Writing a windo

The concept pages each isolate one capability. This guide does the opposite — it builds a single, real Toast.windo.tsx from the first line, folding in configurable props, variants, a typed state machine, placement, and a context as the component asks for them. By the end you'll have read every authoring field in motion, and you'll know which ones run once and which ones run on every render.

We're documenting a Toast component: a small notification with a tone, a title, a description, and a corner it anchors to. It's a good first windo because it exercises the one thing a static gallery can't — a toast has to fire, on demand, and then disappear. That pull-the-trigger interaction is what the state machine exists for, so this component forces us through the interesting part instead of around it.

The skeleton

Every windo is a default-exported call to the windo factory you imported from your config. The factory takes a function that returns the definition object. Three fields are required to render anything: title (the sidebar label), group (which sidebar section it belongs to — type-checked against your configured slugs), and component (what actually paints). One optional sibling of group is worth knowing early: tags, a list of cross-cutting labels drawn from your config's tags that the sidebar lets you filter by.

TSXToast.windo.tsx
import { windo } from './windo.config'
import { Toast } from './components/Toast'

export default windo(() => ({
title: 'Toast',
group: 'feedback',
component: props => <Toast {...props} />,
}))

That alone is a valid windo — it shows up in the Feedback section and renders a toast. But component is handed props it has no way to obtain yet, because we haven't told windo what props this component takes. That's the next field.

Default props

defaultProps is the full, unedited prop object the component starts with. "Full" is the operative word: it may include functions, JSX, anything React accepts — none of which survives JSON editing. So defaultProps is where the rich, non-serialisable props live, and the configurable subset (next section) edits on top of it. Whatever the JSON editor omits falls back to here.

TSXToast.windo.tsx
export default windo<ToastProps>(() => ({
title: 'Toast',
group: 'feedback',
defaultProps: {
  variant: 'success',
  title: 'Changes saved',
  description: 'Everything is up to date.',
  position: 'bottom-right',
},
component: props => <Toast {...props} />,
}))

Note the windo<ToastProps> type argument — it pins the Props generic so defaultProps, variants, and component are all checked against your real component's props. Pass it once and the rest of the definition is typed.

Making props editable

Right now those four props are frozen — the canvas shows one toast and no way to change it. configurableProps opens the ones that can be safely round-tripped through JSON to live controls. You pass it a zod schema wrapped in the configurableProps<Props>()(schema) helper, which constrains the schema's output to a subset of your props so a typo can't slip a non-existent field into the editor.

TSXToast.windo.tsx
import { configurableProps } from '@westopp/windo'
import { z } from 'zod'

const schema = configurableProps<ToastProps>()(
z.object({
  variant: z.enum(['info', 'success', 'warning', 'error']).default('success'),
  title: z.string().default('Changes saved'),
  description: z.string().default('Everything is up to date.'),
  position: z.enum(['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right']).default('bottom-right'),
})
)

The schema lives inside the preview iframe and does the real work: it renders the Controls panel (an enum becomes a select, a string becomes a text field), and on every edit it parses the JSON — running coerce, transforms, and refine — before the value reaches the component. Invalid input surfaces as a per-field error instead of crashing the render. Hand the schema to the definition under configurableProps.

TSXToast.windo.tsx
export default windo<ToastProps>(() => ({
title: 'Toast',
group: 'feedback',
configurableProps: schema,
defaultProps: {
  variant: 'success',
  title: 'Changes saved',
  description: 'Everything is up to date.',
  position: 'bottom-right',
},
component: props => <Toast {...props} />,
}))

Variants: curated presets

A variant is a labelled prop patch that shows up in the gallery as a click-to-apply chip. They're the fast way to show a component's range without making the reader hand-edit JSON — each one merges its props over the current props. Tones are the obvious axis for a toast.

TSXToast.windo.tsx
variants: [
{ label: 'Info', props: { variant: 'info', title: 'Heads up' } },
{ label: 'Warning', props: { variant: 'warning', title: 'Check your input' } },
{ label: 'Error', props: { variant: 'error', title: 'Something broke' } },
],

The patch is partial — a variant overrides only the keys it names and leaves the rest alone. See variants for how they compose with the live editor.

State and actions: firing the toast

Here's the part a static gallery can't do. A toast isn't a thing you display — it's a thing you trigger. windo gives each windo a private, typed state machine: an initial state object and a list of actions that mutate it. The component reads state through ctx.state and the canvas renders each action as a toolbar control.

We'll keep a single counter, tick. A Show toast action bumps it; the component watches the counter and fires a real toast whenever it changes. The State shape is the second type argument to windo.

TSXToast.windo.tsx
export default windo<ToastProps, { tick: number }>(() => ({
title: 'Toast',
group: 'feedback',
state: { tick: 0 },
actions: [
  { label: 'Show toast', run: c => c.setState({ tick: c.state.tick + 1 }) },
  { label: 'Dismiss all', run: () => toast.dismiss() },
],
configurableProps: schema,
defaultProps: {
  variant: 'success',
  title: 'Changes saved',
  description: 'Everything is up to date.',
  position: 'bottom-right',
},
component: (props, ctx) => <Toast {...props} fireKey={ctx.state.tick} />,
}))

Two things changed. component now takes a second argument, ctx — the render context — and reads ctx.state.tick, passing it down as fireKey so the component re-fires when it changes. And each action's run receives that same ctx: c.setState({ tick: c.state.tick + 1 }) merges a patch and re-renders. The merge is shallow, so you only name the keys you're changing.

An action defaults to a toolbar button (the click trigger). The other triggers — enter, exit, hover — bind to the stage's pointer events instead, which is how the Card concept page drives state by hovering the canvas. run also gets an active flag, true on enter / false on leave for hover.

Placement: where it sits in the frame

placement anchors the component inside the resizable canvas. A centred card wants center; a toast that anchors to a corner of the viewport wants the whole stage, so it gets fill. Append -padding to any placement to inset it from the frame edges.

TSXToast.windo.tsx
placement: 'fill',

The full grid — center, fill, the four edges, the four corners, each with a -padding variant — is on the placement page.

Contexts: opting into ambient state

The toast should respect the workbench's light/dark setting. Some of that you get for free: ctx.colorScheme is always on the render context, so the component can read it directly without opting into anything.

TSXToast.windo.tsx
component: (props, ctx) => <Toast {...props} fireKey={ctx.state.tick} theme={ctx.colorScheme} />,

When a component needs an actual React provider mounted around it — a theme provider, a router, an i18n boundary — that's what contexts and the uses field are for. A context defined in your config with a provider is opt-in: list its name in uses and windo wraps this component (and only this one) in it inside the iframe.

TSXToast.windo.tsx
uses: ['theme'],

Free ambient values (ctx.colorScheme, ctx.viewport, ctx.direction, ctx.locale, and any context's controls) need no opt-in. Only a context's provider does — because mounting a wrapper has a cost, and not every component wants it.

The finished windo

All the fields together. This is a real, runnable Toast.windo.tsx.

TSXToast.windo.tsx
import { configurableProps } from '@westopp/windo'
import { toast } from 'sonner'
import { z } from 'zod'
import { Toast, type ToastProps } from './components/Toast'
import { windo } from './windo.config'

const schema = configurableProps<ToastProps>()(
z.object({
  variant: z.enum(['info', 'success', 'warning', 'error']).default('success'),
  title: z.string().default('Changes saved'),
  description: z.string().default('Everything is up to date.'),
  position: z.enum(['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right']).default('bottom-right'),
})
)

export default windo<ToastProps, { tick: number }>(() => ({
title: 'Toast',
group: 'feedback',
status: 'beta',
description: 'Fire one from the toolbar; tune variant/title/description/position in Controls. Toasts stack and auto-dismiss.',
placement: 'fill',
uses: ['theme'],
state: { tick: 0 },
actions: [
  { label: 'Show toast', run: c => c.setState({ tick: c.state.tick + 1 }) },
  { label: 'Dismiss all', run: () => toast.dismiss() },
],
configurableProps: schema,
defaultProps: {
  variant: 'success',
  title: 'Changes saved',
  description: 'Everything is up to date.',
  position: 'bottom-right',
},
variants: [
  { label: 'Info', props: { variant: 'info', title: 'Heads up' } },
  { label: 'Warning', props: { variant: 'warning', title: 'Check your input' } },
  { label: 'Error', props: { variant: 'error', title: 'Something broke' } },
],
component: (props, ctx) => <Toast {...props} fireKey={ctx.state.tick} theme={ctx.colorScheme} />,
}))

We added two cosmetic fields along the way: status: 'beta' paints a badge in the sidebar, and description shows in the chrome. Neither changes behaviour — they document the component for whoever opens the canvas next.

Where next

  • State & actions — the full trigger model: click toolbar buttons, the enter / exit / hover pointer triggers, the active flag, and disabled.
  • Configurable props — how the zod schema becomes controls, and how parsing and per-field errors work inside the iframe.
  • Contexts — defining a provider, ambient controls, and resolve, then opting in with uses.
  • windo() — every field in the definition object, in one table.