Skip to content

Storybook — CSF3, Controls, Hierarchy

FieldValue
TypeSkill Resource
Source~/.copilot/skills/frontend/references/storybook.md
DescriptionNot 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 file
const 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 meta
type Story = StoryObj<typeof meta>
// 2. Stories — one named export per variant
export 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 document
tags: ['autodocs', 'beta'] // experimental — surfaced in the Docs page
tags: ['autodocs', 'deprecated'] // shows a deprecation banner

The nine UI states to cover

Every component story file should cover all states a user can encounter:

StateExample story name
DefaultDefault or Primary
Hover / focusUse a play function to trigger
Active / pressedActive story or play function
DisabledDisabled
LoadingLoading (skeleton or spinner)
ErrorWithError or ErrorState
EmptyEmpty (no data, zero items)
SuccessSuccess (after action completes)
Responsive / long contentLongLabel, 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 file
const meta: Meta<typeof Card> = {
component: Card,
decorators: [
(Story) => (
<ThemeProvider theme={theme}>
<Story />
</ThemeProvider>
),
],
}
// story-level override
export 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 story
export const OnWhite: Story = {
parameters: {
backgrounds: { default: 'white' },
},
}
// Disable all controls for a render-only story
export const Static: Story = {
parameters: {
controls: { disable: true },
},
}
// Set viewport for a responsive story
export const Mobile: Story = {
parameters: {
viewport: { defaultViewport: 'mobile1' },
},
}

Naming conventions

  • File: ComponentName.stories.tsx in the same directory as the component.
  • Story exports: PascalCase, descriptive (WithIcon, DisabledState, not Story1).
  • title path: 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 from meta, 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 — onClick etc. have control: false.
  • No hardcoded JSX in story render unless args genuinely can’t express the variant.
  • Play functions use @storybook/test — not @testing-library/user-event directly.
  • Validated: scripts/check_csf3.sh Component.stories.tsx exits 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

Typecontrol valueUse when
Short text'text'Single-line string props
Long text{ type: 'text' } + textareaMulti-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, ref all have control: false.
  • Types match the prop. A boolean prop has 'boolean', not 'text'.
  • Options exhaustive. select and radio lists 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. Use args at meta level instead.
  • Validated: scripts/check_controls.sh Component.tsx Component.stories.tsx exits 0 — every declared prop has a matching argTypes entry.

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 > Primary
export const meta: Meta<typeof Button> = {
title: 'Design System/Buttons/Primary',
component: Button,
}

Storybook renders this as a nested tree:

▾ Design System
▾ Buttons
Primary

No 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:

Terminal window
# Dry run — see what would change
grep -r "title: 'OldGroup/" src/stories/
# Then edit each file's meta.title

Create 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.tsx file 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 CSF3 meta + StoryObj).
  • Storybook main.ts config — 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 under Design System/Forms/. That’s 8 files — I’ll update meta.title in each. Should I also flatten the current sub-nesting (e.g., Form/Inputs/TextFieldDesign System/Forms/TextField) or preserve it (Design System/Forms/Inputs/TextField)?”

Hierarchy self-rubric

  • Only meta.title changed — 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 — CSF3 meta + StoryObj only.
  • 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>