| name | storybook |
| description | Storybook CSF3 story authoring methodology for UI component catalogs. Prioritizes visual showcase patterns over exhaustive argTypes enumeration. Reference for creating scannable, maintainable component documentation. |
Storybook Skill
Philosophy: Catalog over Controls
Problem with argTypes exhaustion:
- Dozens of stories for every prop combination
- Hard to scan and find what you need
- Maintenance burden grows exponentially
- Controls panel becomes primary interaction
Solution: Visual Showcase Pattern
- One story shows multiple variants in a grid
- Immediate visual comparison
- Self-documenting code
- Controls for interactive exploration only
CSF3 Story Patterns
1. Showcase Story (Primary Pattern)
Display all variants of a prop in a single story:
/**
* All button variants displayed together for visual comparison.
*/
export const Variants: Story = {
render: () => (
<div className="grid grid-cols-2 gap-4 items-center">
<span className="text-sm text-muted-foreground">Primary</span>
<Button variant="primary">Primary</Button>
<span className="text-sm text-muted-foreground">Secondary</span>
<Button variant="secondary">Secondary</Button>
<span className="text-sm text-muted-foreground">Ghost</span>
<Button variant="ghost">Ghost</Button>
<span className="text-sm text-muted-foreground">Destructive</span>
<Button variant="destructive">Delete</Button>
</div>
),
}
2. State Matrix Story
Show component states in a grid:
/**
* Button states: normal, disabled, and loading.
*/
export const States: Story = {
render: () => (
<div className="grid grid-cols-3 gap-4 items-center">
{/* Header row */}
<span className="text-sm text-muted-foreground">Normal</span>
<span className="text-sm text-muted-foreground">Disabled</span>
<span className="text-sm text-muted-foreground">Loading</span>
{/* Primary row */}
<Button>Save</Button>
<Button disabled>Save</Button>
<Button loading>Saving...</Button>
{/* Secondary row */}
<Button variant="secondary">Cancel</Button>
<Button variant="secondary" disabled>Cancel</Button>
<Button variant="secondary" loading>Loading...</Button>
</div>
),
}
3. Use Case Story
Show real-world usage patterns:
/**
* Common button patterns in real UI contexts.
*/
export const UseCases: Story = {
render: () => (
<div className="flex flex-col gap-6">
{/* Form Actions */}
<div className="flex gap-2 justify-end p-4 border border-border rounded-md">
<Button variant="ghost">Cancel</Button>
<Button variant="primary" iconBefore={<Save />}>
Save Changes
</Button>
</div>
{/* Destructive Action */}
<div className="flex gap-2 p-4 border border-border rounded-md">
<Button variant="destructive" iconBefore={<Trash2 />}>
Delete Item
</Button>
</div>
</div>
),
}
4. A11y Test Story (with play function)
Verify accessibility requirements:
/**
* Accessibility test: Verifies aria-label is properly set.
*/
export const A11yAriaLabel: Story = {
args: {
icon: <X />,
"aria-label": "Close panel",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const button = canvas.getByRole("button", { name: "Close panel" })
await expect(button).toBeInTheDocument()
await expect(button).toHaveAccessibleName("Close panel")
},
}
Story Organization
Naming Convention
Components/
├── Button # Component name
│ ├── Variants # Visual showcase
│ ├── Sizes # Size showcase
│ ├── States # State matrix
│ ├── WithIcons # Feature showcase
│ ├── UseCases # Real-world examples
│ ├── A11yBasic # A11y verification
│ └── A11yFocus # A11y verification
Story Order Priority
- Showcase stories first - Visual comparison grids
- Feature stories - Icons, loading, etc.
- Use case stories - Real-world patterns
- A11y test stories - Verification with play functions
Meta Configuration
Minimal argTypes
const meta: Meta<typeof IconButton> = {
title: "Components/IconButton",
component: IconButton,
parameters: {
layout: "centered",
docs: {
description: {
component: `Brief component description with key features.`,
},
},
},
argTypes: {
// Only disable complex/function props
icon: { control: false },
onClick: { control: false },
// Let simple props auto-generate controls
variant: {
control: "select",
options: ["default", "ghost", "destructive"],
},
},
}
Layout Parameters
| Layout | Use Case |
|---|---|
centered |
Single components, buttons, inputs |
padded |
Containers, cards, panels |
fullscreen |
Page layouts, full-width components |
Play Function Patterns
Role-Based Queries (Preferred)
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
// ✅ Query by role
const button = canvas.getByRole("button", { name: "Submit" })
const input = canvas.getByRole("textbox", { name: "Email" })
const menu = canvas.getByRole("menu")
// ✅ Verify accessibility
await expect(button).toHaveAccessibleName("Submit")
await expect(button).not.toBeDisabled()
}
User Interaction
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const user = userEvent.setup()
const button = canvas.getByRole("button", { name: "Open menu" })
await user.click(button)
// Verify state change
await expect(button).toHaveAttribute("aria-expanded", "true")
const menu = canvas.getByRole("menu")
await expect(menu).toBeInTheDocument()
}
Focus Verification
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const button = canvas.getByRole("button")
button.focus()
await expect(button).toHaveFocus()
}
Step Function for Complex Flows
Organize multi-step interactions for better debugging:
import { step } from "@storybook/test"
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const user = userEvent.setup()
await step("Open the dialog", async () => {
await user.click(canvas.getByRole("button", { name: "Settings" }))
await expect(canvas.getByRole("dialog")).toBeInTheDocument()
})
await step("Fill in the form", async () => {
await user.type(canvas.getByLabelText("Name"), "Test User")
await user.selectOptions(canvas.getByLabelText("Theme"), "dark")
})
await step("Submit and verify", async () => {
await user.click(canvas.getByRole("button", { name: "Save" }))
await expect(canvas.getByText("Settings saved")).toBeInTheDocument()
})
}
A11y Testing Checklist
Each component should have stories verifying:
-
aria-label/aria-labelledbyfor unlabeled elements -
aria-expandedfor expandable elements -
aria-pressedfor toggle buttons -
aria-selectedfor selectable items - Focus visibility and keyboard navigation
- Role attributes (
button,menu,tab,tablist, etc.)
Form Component Story Patterns
BorderRadiusCheck Story
Add a story using descender-heavy text ("gggjjjyyyqqqppp") to verify text doesn't clip at rounded corners. Test all size variants.
Recommended Stories for Form Components
Sizes, States, WithLabel, BorderRadiusCheck, A11yWithLabel, A11yDisabled, A11yError
File Structure
packages/ui/
├── .storybook/
│ ├── main.ts # Storybook config
│ ├── preview.ts # Global decorators, a11y config
│ └── storybook.css # Theme import + @source
├── src/
│ └── components/
│ ├── Button.tsx
│ ├── Button.stories.tsx
│ ├── IconButton.tsx
│ └── IconButton.stories.tsx
Configuration
main.ts
import type { StorybookConfig } from "@storybook/react-vite"
import tailwindcss from "@tailwindcss/vite"
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
"@storybook/addon-vitest", // Component testing
"@storybook/addon-a11y", // Accessibility panel
"@storybook/addon-docs", // Documentation
],
framework: "@storybook/react-vite",
async viteFinal(config) {
config.plugins = config.plugins
? [...config.plugins, tailwindcss()]
: [tailwindcss()]
return config
},
}
preview.ts
import type { Preview } from "@storybook/react-vite"
import "./storybook.css"
const preview: Preview = {
parameters: {
a11y: {
config: {
rules: [
{ id: "color-contrast", enabled: true },
{ id: "aria-required-attr", enabled: true },
{ id: "button-name", enabled: true },
{ id: "label", enabled: true },
],
},
},
docs: {
toc: true, // Table of contents in docs
},
},
}
storybook.css
@import "@internal/theme/index.css";
@source "../src/**/*.tsx";
Anti-Patterns
❌ One story per variant
// DON'T: Creates too many sidebar entries
export const Primary: Story = { args: { variant: "primary" } }
export const Secondary: Story = { args: { variant: "secondary" } }
export const Ghost: Story = { args: { variant: "ghost" } }
export const Destructive: Story = { args: { variant: "destructive" } }
✅ Single showcase story
// DO: One story showing all variants
export const Variants: Story = {
render: () => (
<div className="grid grid-cols-2 gap-4">
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="destructive">Destructive</Button>
</div>
),
}
❌ Relying on Controls for documentation
// DON'T: Users must interact with controls to see variants
export const Default: Story = {
args: {
variant: "primary",
size: "md",
},
}
✅ Visual documentation
// DO: All variants visible at once
export const Sizes: Story = {
render: () => (
<div className="flex items-center gap-4">
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</div>
),
}
Portable Stories (Storybook 8+)
Use stories in external test runners:
// Button.test.tsx
import { composeStories } from "@storybook/react"
import { render, screen } from "@testing-library/react"
import * as stories from "./Button.stories"
const { Primary, Disabled } = composeStories(stories)
test("renders primary button", () => {
render(<Primary />)
expect(screen.getByRole("button")).toBeInTheDocument()
})
test("disabled button is not interactive", () => {
render(<Disabled />)
expect(screen.getByRole("button")).toBeDisabled()
})
Benefits:
- Reuse stories in Vitest/Jest
- Single source of truth for component states
- Decorators and args automatically applied