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:
tsx
/**
* 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:
tsx
/**
* 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:
tsx
/**
* 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:
tsx
/**
* 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
code
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
tsx
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)
tsx
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
tsx
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
tsx
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:
tsx
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
code
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
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
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
css
@import "@internal/theme/index.css"; @source "../src/**/*.tsx";
Anti-Patterns
❌ One story per variant
tsx
// 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
tsx
// 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
tsx
// DON'T: Users must interact with controls to see variants
export const Default: Story = {
args: {
variant: "primary",
size: "md",
},
}
✅ Visual documentation
tsx
// 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:
tsx
// 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()
})
A11y Testing with vitest-axe
tsx
// accessibility.test.tsx
import { composeStories } from "@storybook/react"
import { render } from "@testing-library/react"
import { axe } from "vitest-axe"
import * as ButtonStories from "./Button.stories"
import * as TreeViewStories from "./TreeView.stories"
const { Default: ButtonDefault } = composeStories(ButtonStories)
const { Default: TreeViewDefault } = composeStories(TreeViewStories)
describe("Accessibility", () => {
test("Button axe-core check", async () => {
const { container } = render(<ButtonDefault />)
expect(await axe(container)).toHaveNoViolations()
})
test("TreeView axe-core check", async () => {
const { container } = render(<TreeViewDefault />)
expect(await axe(container)).toHaveNoViolations()
})
})
Benefits:
- •Reuse stories in Vitest/Jest
- •Single source of truth for component states
- •Decorators and args automatically applied
- •Faster a11y validation than E2E (milliseconds)