Storybook is the industry standard for component development in isolation — but most teams use it as read-only documentation rather than an active testing tool. Claude Code generates stories that cover real usage patterns, interaction tests that replace some Playwright tests, and Chromatic integration that catches visual regressions before they reach production.
This guide covers Storybook with Claude Code: writing stories, interaction tests, visual regression, and component documentation.
Storybook Setup
CLAUDE.md for Storybook Projects
## Storybook Component Library
- Storybook 8.x with React 18 + TypeScript + Vite
- Testing: @storybook/test (vitest-based)
- Visual regression: Chromatic (linked to GitHub)
- Accessibility: @storybook/addon-a11y (axe-core integration)
## Story conventions
- One story file per component: ComponentName.stories.tsx
- Required story per component: Default, with all required props
- Additional stories: key variants and states (Loading, Error, Empty, etc.)
- Use args for interactive controls — not hardcoded JSX
- Interaction tests: test() blocks in stories, not separate test files
## What NOT to put in stories
- Complex business logic stories — demonstrate UI states only
- Stories that require network calls — use MSW for API mocking
- Stories with real Supabase/Stripe/etc. — use mock service worker
## Story naming
- Default: renders with minimal required props
- Variant names: WithIcon, Disabled, Loading, Error, Empty, Selected
- Don't name stories after scenarios ("UserClicksButton") — name by state
Component Story Structure
Write comprehensive stories for a DataTable component.
Cover: default, loading, empty, error, with selection, with sorting.
// src/components/DataTable/DataTable.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import { DataTable } from './DataTable';
// Column definitions for stories
const columns = [
{ key: 'name', header: 'Name', sortable: true },
{ key: 'email', header: 'Email', sortable: true },
{ key: 'role', header: 'Role' },
{ key: 'status', header: 'Status', cell: (row) => <StatusBadge status={row.status} /> },
];
const users = Array.from({ length: 20 }, (_, i) => ({
id: `user-${i}`,
name: `User ${i + 1}`,
email: `user${i + 1}@example.com`,
role: i % 3 === 0 ? 'Admin' : 'Member',
status: i % 4 === 0 ? 'inactive' : 'active',
}));
const meta: Meta<typeof DataTable> = {
title: 'Components/DataTable',
component: DataTable,
tags: ['autodocs'],
args: {
// Default args — all stories inherit these
columns,
onSort: fn(),
onRowSelect: fn(),
onPageChange: fn(),
},
};
export default meta;
type Story = StoryObj<typeof DataTable>;
// Default — most common usage
export const Default: Story = {
args: {
data: users,
totalRows: users.length,
},
};
// Loading skeleton
export const Loading: Story = {
args: {
data: [],
loading: true,
},
};
// Empty state
export const Empty: Story = {
args: {
data: [],
loading: false,
emptyMessage: 'No users found. Invite your team to get started.',
},
};
// With row selection
export const WithSelection: Story = {
args: {
data: users,
selectable: true,
selectedIds: ['user-0', 'user-3'],
totalRows: users.length,
},
};
// Sorted column
export const SortedByName: Story = {
args: {
data: [...users].sort((a, b) => a.name.localeCompare(b.name)),
totalRows: users.length,
sortColumn: 'name',
sortDirection: 'asc',
},
};
// Paginated — page 2
export const Paginated: Story = {
args: {
data: users.slice(10, 20),
totalRows: 87,
currentPage: 2,
pageSize: 10,
},
};
Interaction Tests
Add interaction tests to the DataTable stories.
Test that sorting works when you click a column header.
import { expect, userEvent, within } from '@storybook/test';
export const SortInteraction: Story = {
args: {
data: users,
totalRows: users.length,
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
// Click the Name column header to sort
const nameHeader = canvas.getByRole('columnheader', { name: /name/i });
await userEvent.click(nameHeader);
// Verify the onSort callback was called with correct args
expect(args.onSort).toHaveBeenCalledWith({ column: 'name', direction: 'asc' });
// Click again to reverse sort direction
await userEvent.click(nameHeader);
expect(args.onSort).toHaveBeenCalledWith({ column: 'name', direction: 'desc' });
},
};
export const SelectAllInteraction: Story = {
args: {
data: users.slice(0, 5),
totalRows: 5,
selectable: true,
selectedIds: [],
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
// Click "select all" checkbox
const selectAll = canvas.getByRole('checkbox', { name: /select all/i });
await userEvent.click(selectAll);
// All rows should be selected
expect(args.onRowSelect).toHaveBeenCalledWith(
expect.arrayContaining(users.slice(0, 5).map(u => u.id))
);
// Check individual checkboxes are marked
const checkboxes = canvas.getAllByRole('checkbox');
checkboxes.forEach(checkbox => expect(checkbox).toBeChecked());
},
};
Run interaction tests in CI:
# Runs in headless mode — no browser window
npx storybook test --ci
MSW for API Mocking
The UserProfile component fetches data from our API.
Set up MSW so it works in Storybook without hitting the real API.
// .storybook/preview.ts
import { initialize, mswLoader } from 'msw-storybook-addon';
initialize();
export const loaders = [mswLoader];
// src/components/UserProfile/UserProfile.stories.tsx
import { http, HttpResponse } from 'msw';
export const Default: Story = {
parameters: {
msw: {
handlers: [
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'Alice Johnson',
email: '[email protected]',
bio: 'Senior frontend engineer. Coffee enthusiast.',
avatar: 'https://i.pravatar.cc/150?img=1',
});
}),
],
},
},
};
export const NotFound: Story = {
parameters: {
msw: {
handlers: [
http.get('/api/users/:id', () => {
return HttpResponse.json({ error: 'User not found' }, { status: 404 });
}),
],
},
},
};
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get('/api/users/:id', async () => {
await new Promise(resolve => setTimeout(resolve, 10000)); // Never resolves
return HttpResponse.json({});
}),
],
},
},
};
Visual Regression with Chromatic
Set up Chromatic to catch visual regressions on every PR.
# .github/workflows/chromatic.yml
name: Visual Regression
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
chromatic:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Chromatic needs git history for base comparisons
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Publish to Chromatic
uses: chromaui/action@latest
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
exitZeroOnChanges: true # PR comments on changes without failing
autoAcceptChanges: main # Auto-accept on main (no review needed)
On every PR, Chromatic renders each story in a headless browser, diffs against the previous accepted baseline, and posts a review link to the PR. Visual changes require explicit acceptance — which happens in the Chromatic UI, not just by merging the PR.
For testing components beyond Storybook — integration tests, E2E tests — see the testing strategies guide for where each tool fits. For React component patterns that make components easier to document in Storybook, the code review guide covers component design principles. The Claude Skills 360 bundle includes Storybook skill sets for component documentation and visual testing. Start with the free tier to generate stories for your component library.