Skip to content

Story Authoring — CSF3 Patterns

FieldValue
TypeAgent Reference
Source~/.copilot/agents/_refs/storybook-designer/story-authoring.md
DescriptionNot specified

Source Content

Story Authoring — CSF3 Patterns

The Canonical Meta Block

import type { Meta, StoryObj } from '@storybook/react-vite'
import { createComponentDocs } from '@/lib/storybook-docs'
import { MyComponent } from './my-component'
const meta = {
component: MyComponent,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: createComponentDocs({
summary: 'One-sentence plain-English description.',
when: 'Use when the user needs to do X in context Y.',
whenNot: 'Do not use for Z — use AlternativeComponent instead.',
motion: 'Fades in on mount, 150ms ease-out.',
reducedMotion: 'No animation. Component appears immediately.',
}),
},
},
},
} satisfies Meta<typeof MyComponent>
export default meta
type Story = StoryObj<typeof meta>

Rules:

  • Always satisfies Meta<typeof Component> — never Meta<typeof Component> without satisfies.
  • Always tags: ['autodocs'].
  • Always createComponentDocs(...) — never write raw strings into the description.
  • motion and reducedMotion are required for any component with animation.

Story Naming Conventions

Name stories after the state or scenario, not the variant prop:

// GOOD — describes what the user sees
export const Default: Story = { args: { ... } }
export const Loading: Story = { args: { isLoading: true } }
export const ErrorState: Story = { args: { error: 'Failed to load' } }
export const Empty: Story = { args: { items: [] } }
export const Disabled: Story = { args: { disabled: true } }
export const WithLongContent: Story = { args: { title: 'A very long title that might wrap...' } }
export const InsideCard: Story = { /* shows component in a card host */ }
// BAD — describes the prop, not the experience
export const IsLoadingTrue: Story = { }
export const VariantDestructive: Story = { }

Args vs Render

Use args when:

  • The story is fully driven by serializable props
  • Controls should work automatically
  • No special layout or wrapper needed
export const Default: Story = {
args: {
label: 'Save changes',
variant: 'default',
size: 'md',
},
}

Use render when:

  • The component needs a wrapper for layout (but NOT for a border — see ADR-008)
  • The component has children that need to be concrete (not abstract args)
  • You need to show compound component composition
export const WithActions: Story = {
render: (args) => (
<div className="flex gap-2">
<Button {...args} variant="default">Save</Button>
<Button {...args} variant="ghost">Cancel</Button>
</div>
),
}

Never define function Demo() inside a render callback — use the arrow function directly:

// BAD
export const Default: Story = {
render: (args) => {
function Demo() { return <MyComponent {...args} /> }
return <Demo />
}
}
// GOOD
export const Default: Story = {
render: (args) => <MyComponent {...args} />,
}

Realistic Data

Product stories must use plausible, domain-appropriate data:

// BAD — placeholder data
const mockUser = { id: '1', name: 'string', email: 'email@test.com', role: 'role' }
// GOOD — realistic data
const mockUser = {
id: 'usr_01HXYZ',
name: 'Sarah Okafor',
email: 'sarah.okafor@lawfirm.com',
role: 'admin' as const,
createdAt: '2026-03-14T09:30:00Z',
}

Compound Component Stories (ADR-011)

Register all sub-components in meta.subcomponents. Use the compound API directly in render — no wrapper components:

import { FilterPanel } from './filter-panel'
const meta = {
component: FilterPanel,
subcomponents: {
'FilterPanel.Search': FilterPanel.Search,
'FilterPanel.DateRange': FilterPanel.DateRange,
'FilterPanel.Status': FilterPanel.Status,
},
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: createComponentDocs({
summary: 'Collapsible filter panel with named content regions.',
when: 'Sidebar filters for list or table views.',
whenNot: 'Inline filter chips — use ChipGroup instead.',
}),
},
source: {
// Paste a complete, realistic copy-paste-ready usage example
code: `
import { FilterPanel } from '@dmwd-io/design-system'
<FilterPanel title="Filter cases">
<FilterPanel.Search placeholder="Search by case name..." />
<FilterPanel.DateRange label="Filed between" />
<FilterPanel.Status options={[{ label: 'Open', value: 'open' }, { label: 'Closed', value: 'closed' }]} />
</FilterPanel>
`.trim(),
},
},
},
} satisfies Meta<typeof FilterPanel>
export const Default: Story = {
render: (args) => (
<FilterPanel {...args} title="Filter cases">
<FilterPanel.Search placeholder="Search by case name..." />
<FilterPanel.DateRange label="Filed between" />
<FilterPanel.Status options={mockStatusOptions} />
</FilterPanel>
),
}

Interaction Tests (play functions)

Write play functions for any story testing user interaction. These run in CI via the Storybook test runner:

import { userEvent, within, expect } from '@storybook/test'
export const SelectsOption: Story = {
args: {
options: [
{ label: 'Open', value: 'open' },
{ label: 'Closed', value: 'closed' },
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
// Open the dropdown
await userEvent.click(canvas.getByRole('combobox'))
// Verify options are visible
await expect(canvas.getByRole('option', { name: 'Open' })).toBeVisible()
// Select an option
await userEvent.click(canvas.getByRole('option', { name: 'Closed' }))
// Verify selection
await expect(canvas.getByRole('combobox')).toHaveTextContent('Closed')
},
}
export const KeyboardNavigation: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const trigger = canvas.getByRole('combobox')
// Focus and open with keyboard
await userEvent.tab()
await expect(trigger).toHaveFocus()
await userEvent.keyboard('{Enter}')
// Navigate with arrow keys
await userEvent.keyboard('{ArrowDown}')
await userEvent.keyboard('{ArrowDown}')
await userEvent.keyboard('{Enter}')
// Verify dismissal on Escape
await userEvent.keyboard('{Escape}')
await expect(trigger).toHaveFocus()
},
}

Story File Location

Stories live in the component’s own directory, not a shared stories folder:

components/
filter-panel/
index.tsx
filter-panel.tsx
filter-panel.stories.tsx ← here, not in a /stories folder
filter-panel.test.tsx

For Layer 2 components in the design system, the Storybook title determines the sidebar position:

const meta = {
title: 'Forms/TextField', // Category/ComponentName
component: TextField,
// ...
} satisfies Meta<typeof TextField>

Title conventions:

  • Foundations/... — token galleries, foundation documentation
  • Primitives/... — buttons, badges, chips, links
  • Forms/... — inputs, selects, date pickers, file upload
  • Feedback/... — toasts, banners, callouts, skeletons, spinners
  • Navigation/... — tabs, breadcrumbs, pagination, side nav
  • Data Display/... — tables, data grids, cards, charts
  • Overlays/... — dialogs, drawers, popovers, tooltips, command palette
  • Application Patterns/... — Layer 3 composed patterns only

After changing any title, run pnpm run catalog.