Storybook Patterns
Quick Guide: A story is a component in one state, written as a plain object in CSF 3.0: a default
metaexport describing the component, then one named export per state. Props come fromargsso the controls panel can edit them,argTypesshapes those controls,tags: ["autodocs"]turns the file into a documentation page, and aplayfunction drives interactions with@storybook/testso the same story doubles as a test.
Detailed Resources:
- examples/core.md — CSF 3.0 files, args inheritance, argTypes, decorators, render functions, story organisation
- examples/testing.md — play functions, composed flows, keyboard navigation, visual and accessibility parameters
- examples/docs.md — autodocs, custom docs pages, MDX, source display, descriptions
- examples/addons.md — addon setup, viewports, backgrounds, the test addon, configuration decisions
- reference.md — control types, parameters, addon list,
@storybook/testAPI, file naming, migration notes
Which path applies
A story file usually serves both, but the two make different demands on how it is written.
- Documenting a component — every meaningful state is an export, props are args so they can be edited, and
tags: ["autodocs"]generates the page. Follow examples/docs.md. - Testing a component — the story is a fixture,
fn()args record calls, and aplayfunction performs the interaction and asserts. Follow examples/testing.md.
Before writing stories
Write CSF 3.0 with satisfies Meta<typeof Component>. The satisfies clause type-checks meta against the component's props while keeping the inference StoryObj<typeof meta> needs, so every story's args are checked against the real prop types.
Give meta a component. Controls are inferred from its props and autodocs documents it, so a meta without one produces a page with no props table and stories that may not render.
Express state through args, not through JSX. The controls panel edits args; props hardcoded in a render are invisible to it, and the story stops being explorable.
Drive interactions from a play function using @storybook/test. Its userEvent reproduces the event sequence a real input produces, and fn() args record calls so the assertion can be about behaviour rather than markup.
Export stories as named exports. The default export is the meta, and a file can only have one.
Auto-detection: Storybook, .stories.tsx, .stories.ts, CSF, Meta, StoryObj, satisfies Meta, args, argTypes, play function, canvasElement, within, autodocs, decorators, parameters, @storybook/test, .storybook/main.ts, .storybook/preview.ts
Applies to:
- Building a component in isolation, without the app's routing, data or auth
- Documenting a component API so the documentation cannot drift from the code
- Interaction tests that run against the rendered component in a browser
- Exposing every state of a component — loading, empty, error, disabled — as something reviewable
Handled elsewhere:
- Full user journeys across pages, which need the whole application running
- Unit tests of logic with no rendered output
- The actual styling of components; stories render whatever the component already produces
- Baseline custody and diff review for screenshots — this skill settles which stories are captured and with what parameters, not what happens to the images
- Network responses the component depends on, which reach it through whatever mocking the project uses
Storybook is component-driven development made concrete: build from the bottom up, and let each component exist before the screen that uses it does.
A story is a documented example rather than a test case. It captures one meaningful state — primary, disabled, loading, error — and its value is that a person can look at it. A play function then turns that same example into a test without changing what it documents, which is why the assertion belongs to the story rather than the story being written for the assertion.
Args or a render function
Do all the variations come from props?
├─ YES → args only
└─ NO → Children or composition?
├─ Simple (text, one element) → args.children
└─ Complex (several elements) → render: (args) => …
└─ Needs state to demonstrate? → render: function Render(args) { … }
(a named function, so hooks are legal)
A render that keeps growing is usually the component's API asking for the composition the render is faking.
What to add to a story
Interaction to verify? → play function with @storybook/test
Appearance to lock down? → visual regression parameters on the story
Accessibility to check? → a11y parameters; the addon runs axe on every story
Nothing to verify yet? → leave it as a documented state
Which components get stories
Primitives document every variant; composed components show the compositions that are actually used; page-level components show the states that are hard to reach by hand — loading, empty, error. A component with no visual output gets none.
Core patterns
Pattern 1: CSF 3.0 Story Format
A typed meta default export, then one named export per state.
const meta = {
title: "Components/Button",
component: Button,
tags: ["autodocs"],
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: { variant: "primary", children: "Save" },
};
title decides where the component sits in the sidebar; nested segments ("Components/Forms/Input") create the hierarchy.
Full code: examples/core.md
Pattern 2: Args and ArgTypes
args are the props a story renders with; argTypes describe the control that edits them. Defaults on meta are inherited, so each story states only what makes it different.
const meta = {
component: Card,
args: { title: "Card title" },
argTypes: {
variant: { control: { type: "select" }, options: ["default", "outline"] },
padding: { control: { type: "range", min: 0, max: 64, step: 4 } },
internalId: { table: { disable: true } },
},
} satisfies Meta<typeof Card>;
Args must be serializable — a function arg is fn() from @storybook/test, and a non-serializable value such as a React element reaches a control through mapping.
Full code, and the control type per prop type: examples/core.md · reference.md
Pattern 3: Decorators for Context
A decorator wraps the story: providers a component needs, or layout it needs to be visible.
const meta = {
component: Modal,
decorators: [
(Story) => (
<div style={{ padding: "3rem" }}>
<Story />
</div>
),
],
} satisfies Meta<typeof Modal>;
Global decorators go in .storybook/preview, component decorators on meta, and story decorators on the story. They run outermost to innermost, so order matters wherever one provider depends on another.
Full code: examples/core.md
Pattern 4: Parameters
Parameters configure Storybook and its addons, at whichever level the setting belongs to — global, component or story.
parameters: {
layout: "centered",
viewport: { defaultViewport: "mobile1" },
docs: { description: { component: "A modal dialog for confirmations." } },
},
A story-level parameter overrides the component's, which overrides the global one.
Full list: reference.md
Pattern 5: Play Functions
A play function runs after render, inside the rendered canvas.
export const Submitted: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText(/email/i), "user@example.com");
await userEvent.click(canvas.getByRole("button", { name: /sign in/i }));
await expect(args.onSubmit).toHaveBeenCalled();
},
};
within(canvasElement) scopes queries to this story rather than the whole page. Every userEvent call is awaited, and a story's play function can be called from another's to build a multi-step flow.
Full code: examples/testing.md
Pattern 6: Render Functions
Use render when a story needs more than one element, or state to demonstrate the component.
export const WithItems: Story = {
render: (args) => (
<List {...args}>
{ITEMS.map((item) => (
<ListItem key={item}>{item}</ListItem>
))}
</List>
),
args: { variant: "default" },
};
Spread args into the component so the controls still work. A render function needing hooks is written as a named function (render: function Render(args) { … }), since hooks are only legal inside a component.
Full code: examples/core.md
Pattern 7: Tags
Tags decide where a story appears and what runs against it — autodocs generates the page, dev shows it in the sidebar, test includes it in test runs. Inherited tags are removed with a ! prefix.
export const Experimental: Story = {
tags: ["!test", "experimental"],
};
Full code: examples/addons.md
Red flags
Breaks at runtime:
- A second default export for a story — a module has one default export, which the meta already is
metawithoutcomponent— controls have no props to infer from, autodocs has nothing to document, and the story may not render at alluserEventcalls that are not awaited — the assertion runs before the interaction finishes, so the story fails or passes depending on timing- A
renderfunction using hooks while written as an arrow expression — hooks need a named function that Storybook can treat as a component - A non-serializable arg passed directly — functions need
fn(), and elements needmapping, or the control panel cannot round-trip the value
Surprising behaviour:
- Props hardcoded in a
renderinstead of passed as args — controls silently do nothing, and the docs page shows a component nobody can experiment with fireEventin a play function — it dispatches one event where a real interaction produces a sequence, so focus, hover and keyboard behaviour go untested- Actions inferred by pattern (
argTypesRegex) rather than declared withfn()— an implicit action cannot be asserted on inside a play function - Assertions in a story body rather than in
play— they run at module evaluation, before anything is rendered - Play functions run after render, so content that arrives asynchronously needs
waitForor afindByquery rather than agetBy - Decorators run outermost to innermost — a provider that depends on another has to be listed after it
- Args edited in the controls panel survive a hot reload, so a change to the story's own
argscan appear to have no effect until the page is refreshed - Missing
tags: ["autodocs"]produces a story with no documentation page and no warning that one was expected - Business logic inside a play function tests the logic through the DOM, where a unit test would say more and run faster
Version-specific behaviour — deprecated fields, renamed globals, removed packages — is in reference.md rather than here, because which of them applies depends on the major you are on.