Storybook — CSF3, Controls, Hierarchy
| Field | Value |
|---|---|
| Type | Skill Resource |
| Source | ~/.copilot/skills/frontend/references/storybook.md |
| Description | Not specified |
Source Content
Storybook — CSF3, Controls, Hierarchy
Everything about authoring, wiring, and organizing Storybook stories for @dmwd-io/design-system and consuming apps. Absorbed from the former storybook-csf3, storybook-controls, and storybook-hierarchy skills.
Part 1 — CSF3 story setup
Canonical CSF3 file structure:
import type { Meta, StoryObj } from '@storybook/react'import { Button } from './Button'
// 1. Meta — one per file, sets defaults for all stories in this fileconst meta: Meta<typeof Button> = { title: 'Design System/Buttons/Button', // sidebar path component: Button, tags: ['autodocs'], // generates the Docs page args: { // default prop values shown in every story label: 'Click me', variant: 'primary', }, argTypes: { onClick: { control: false }, },}
export default metatype Story = StoryObj<typeof meta>
// 2. Stories — one named export per variantexport const Primary: Story = { args: { variant: 'primary' },}
export const Secondary: Story = { args: { variant: 'secondary' },}
export const Disabled: Story = { args: { disabled: true },}Tags and autodocs
tags: ['autodocs'] on meta tells Storybook to generate a Docs page for this component automatically — it renders all stories in a grid with the controls table. Add it to every new component story unless there’s a reason to opt out.
Other useful tags:
tags: ['autodocs', 'stable'] // stable API, safe to documenttags: ['autodocs', 'beta'] // experimental — surfaced in the Docs pagetags: ['autodocs', 'deprecated'] // shows a deprecation bannerThe nine UI states to cover
Every component story file should cover all states a user can encounter:
| State | Example story name |
|---|---|
| Default | Default or Primary |
| Hover / focus | Use a play function to trigger |
| Active / pressed | Active story or play function |
| Disabled | Disabled |
| Loading | Loading (skeleton or spinner) |
| Error | WithError or ErrorState |
| Empty | Empty (no data, zero items) |
| Success | Success (after action completes) |
| Responsive / long content | LongLabel, Overflow |
Play functions for interaction testing
import { expect, userEvent, within } from '@storybook/test'
export const ClickOpensMenu: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement) const trigger = canvas.getByRole('button', { name: /menu/i })
await userEvent.click(trigger)
await expect( canvas.getByRole('menu') ).toBeVisible() },}Decorators
Use decorators to wrap stories in providers (theme, router, query client) without repeating boilerplate per story:
// meta-level — applies to all stories in this fileconst meta: Meta<typeof Card> = { component: Card, decorators: [ (Story) => ( <ThemeProvider theme={theme}> <Story /> </ThemeProvider> ), ],}
// story-level overrideexport const DarkTheme: Story = { decorators: [ (Story) => ( <ThemeProvider theme={darkTheme}> <Story /> </ThemeProvider> ), ],}Global decorators live in .storybook/preview.tsx — use them for app-wide providers (router, query client, i18n).
Parameters
parameters passes config to addons — not to the component:
// Disable the backgrounds addon for this storyexport const OnWhite: Story = { parameters: { backgrounds: { default: 'white' }, },}
// Disable all controls for a render-only storyexport const Static: Story = { parameters: { controls: { disable: true }, },}
// Set viewport for a responsive storyexport const Mobile: Story = { parameters: { viewport: { defaultViewport: 'mobile1' }, },}Naming conventions
- File:
ComponentName.stories.tsxin the same directory as the component. - Story exports: PascalCase, descriptive (
WithIcon,DisabledState, notStory1). titlepath: mirrors the design system hierarchy, not the filesystem (Design System/Buttons/Button).
CSF3 self-rubric
-
export default meta— meta is the default export; stories are named exports. -
type Story = StoryObj<typeof meta>— typed frommeta, not the component directly. -
tags: ['autodocs']— present unless explicitly opted out. - Nine UI states covered — at minimum: default, disabled, loading, error, empty.
- Event handlers disabled in argTypes —
onClicketc. havecontrol: false. - No hardcoded JSX in story
renderunlessargsgenuinely can’t express the variant. - Play functions use
@storybook/test— not@testing-library/user-eventdirectly. - Validated:
scripts/check_csf3.sh Component.stories.tsxexits 0.
scripts/check_csf3.sh <Component.stories.tsx> checks the default-meta/title/component/tags: ['autodocs']/named-Story-export shape and flags any leftover CSF2 Template.bind({}) pattern.
Part 2 — Controls and argTypes
Controls live in argTypes on the meta object (global defaults) or on individual StoryObj (story-level overrides). Storybook infers basic controls from TypeScript types — argTypes lets you override, constrain, or disable those inferences.
export const meta: Meta<typeof Button> = { title: 'Components/Button', component: Button, argTypes: { // override inferred control type variant: { control: 'select', options: ['primary', 'secondary', 'ghost'], }, // disable a control entirely onClick: { control: false }, // add description shown in the controls panel size: { control: 'radio', options: ['sm', 'md', 'lg'], description: 'Visual size of the button', }, },}Control types reference
| Type | control value | Use when |
|---|---|---|
| Short text | 'text' | Single-line string props |
| Long text | { type: 'text' } + textarea | Multi-line content |
| Boolean | 'boolean' | true/false toggle |
| Number | 'number' | Numeric input |
| Range slider | { type: 'range', min, max, step } | Bounded numeric |
| Select dropdown | 'select' | Fixed set of string options |
| Radio buttons | 'radio' | Small fixed set (≤5), always visible |
| Multi-select | 'multi-select' | Array of options |
| Color picker | 'color' | CSS color string |
| Date picker | 'date' | Date string props |
| Object editor | 'object' | Inline JSON for object props |
| File picker | 'file' | accept regex for file props |
| Disabled | { control: false } | Event handlers, internal props |
Grouping controls into categories
Use table.category to group related controls in the panel:
argTypes: { label: { table: { category: 'Content' } }, icon: { table: { category: 'Content' } }, disabled: { table: { category: 'State' } }, loading: { table: { category: 'State' } }, onClick: { control: false, table: { category: 'Events' } },}Disabling auto-inferred controls
TypeScript inference creates controls for every prop. Disable the ones that shouldn’t be interactive:
argTypes: { // event handlers — never interactive onClick: { control: false }, onChange: { control: false }, onBlur: { control: false }, // internal/forwarded ref props ref: { control: false }, className: { control: false },}To disable all controls for a story (render-only, no interaction):
export const Static: StoryObj<typeof Card> = { parameters: { controls: { disable: true }, },}Story-level overrides
Override meta argTypes for a specific story:
export const WithLongLabel: StoryObj<typeof Button> = { args: { label: 'This is a very long label string' }, argTypes: { // show a textarea just for this story label: { control: { type: 'text' } }, },}Default args vs. argTypes
args= the default values shown in the controls panel when the story loads.argTypes= the shape and type of each control.
Always set args at the meta level for shared defaults; override per story only when the variation genuinely differs.
export const meta: Meta<typeof Badge> = { component: Badge, args: { label: 'Default label', // shown in every story unless overridden variant: 'info', },}Controls self-rubric
- Event handlers disabled.
onClick,onChange,onBlur,refall havecontrol: false. - Types match the prop. A boolean prop has
'boolean', not'text'. - Options exhaustive.
selectandradiolists include every valid value (check the TypeScript union). - Default args set at meta level. Not repeated per story unless the story genuinely needs a different default.
- No deprecated
argTypes.defaultValue. Useargsatmetalevel instead. - Validated:
scripts/check_controls.sh Component.tsx Component.stories.tsxexits 0 — every declared prop has a matchingargTypesentry.
scripts/check_controls.sh <Component.tsx> <Component.stories.tsx> flags any prop declared on the component’s Props interface with no corresponding entry in the story file’s argTypes block.
Part 3 — Hierarchy and organization (moves)
The cardinal rule: moving a story in Storybook = editing meta.title. It never means moving the file.
The file can stay exactly where it is on disk. The Storybook sidebar tree is built entirely from the title string in each story’s meta export. Changing the path string changes where the story appears in the UI. Full stop.
How meta.title builds the tree
The title string uses / as a path separator. Each segment becomes a collapsible folder node in the sidebar:
// Appears at: Design System > Buttons > Primaryexport const meta: Meta<typeof Button> = { title: 'Design System/Buttons/Primary', component: Button,}Storybook renders this as a nested tree:
▾ Design System ▾ Buttons PrimaryNo files moved. No imports changed (unless the component import path itself was wrong). Just the title string.
Move a story to a different folder
Change only the title in the story file’s meta:
// Before — sitting at top-level "Components"export const meta: Meta<typeof Card> = { title: 'Components/Card', component: Card,}
// After — moved under "Design System > Data Display"export const meta: Meta<typeof Card> = { title: 'Design System/Data Display/Card', component: Card,}The .stories.tsx file does not move. The Card import does not change.
Rename a folder
A “folder” in Storybook doesn’t exist as a real directory — it’s implied by the shared prefix in title strings. To rename a folder, update the prefix in every story that references it:
// Before — all under "Legacy"title: 'Legacy/Button'title: 'Legacy/Input'title: 'Legacy/Modal'
// After — renamed to "Deprecated"title: 'Deprecated/Button'title: 'Deprecated/Input'title: 'Deprecated/Modal'Move multiple stories to a new group
Find all stories sharing the old prefix and update them in one pass:
# Dry run — see what would changegrep -r "title: 'OldGroup/" src/stories/
# Then edit each file's meta.titleCreate a new top-level category
Simply use a new prefix string — no directory, no config, no registration required:
title: 'NewCategory/ComponentName'Storybook picks it up on the next reload.
What does NOT change
- The
.stories.tsxfile location on disk. - The component import path inside the story file (unless the component itself was also moved, which is a separate task).
storiesOf()(legacy API — if you see this, flag it; migrate to CSF3meta+StoryObj).- Storybook
main.tsconfig — no registration needed for individual stories.
Multi-file moves — ask first
If a request involves reorganizing many stories at once, confirm the target hierarchy before editing. Example prompt:
“You want to move all
Form/stories underDesign System/Forms/. That’s 8 files — I’ll updatemeta.titlein each. Should I also flatten the current sub-nesting (e.g.,Form/Inputs/TextField→Design System/Forms/TextField) or preserve it (Design System/Forms/Inputs/TextField)?”
Hierarchy self-rubric
- Only
meta.titlechanged — no files moved, no imports touched unless the component path was independently wrong. - Path separator is
/— not., not>, not a space-separated string. - Confirmed the target path — asked or inferred the exact destination hierarchy before editing.
- Multi-file moves listed explicitly — named every file being touched before applying changes.
- No
storiesOf()introduced — CSF3meta+StoryObjonly. - Validated:
scripts/check_hierarchy.sh <dir>exits 0 across every touched story file.
scripts/check_hierarchy.sh <dir> scans every *.stories.tsx under <dir> and flags any meta.title that looks like a copy-pasted filesystem path instead of a Category/Name hierarchy label.
</content>