
Storybook Stories
- 81 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
storybook-stories is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- storybook-stories
- AI & Agent Building
- AI-coding skill
Storybook Stories by the numbers
- 81 all-time installs (skills.sh)
- +2 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,216 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill storybook-storiesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 81 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Storybook Stories
Overview
Storybook is a frontend workshop for building UI components in isolation. Stories are written in Component Story Format 3 (CSF3), which uses object syntax with args for type-safe component documentation and testing.
When to use: Component documentation with live examples, interaction testing with play functions, visual regression testing, accessibility validation, design system maintenance, isolated component development.
When NOT to use: End-to-end testing (use Playwright/Cypress), API integration testing (use Vitest/Jest), full application testing (use browser automation), performance testing (use Lighthouse/WebPageTest).
Quick Reference
| Pattern | API | Key Points |
|---|---|---|
| Basic story | export const Default: Story = { args } | Use args for simple single components |
| Complex story | export const Example: Story = { render } | Use render for multi-component layouts |
| Meta configuration | const meta = { component, args } satisfies Meta | Define defaults and argTypes |
| Interaction test | play: async ({ canvas, userEvent, args }) | canvas and userEvent provided directly |
| User interaction | await userEvent.click(element) | Always await userEvent methods |
| Query elements | canvas.getByRole('button') | Prefer getByRole over other queries |
| Assertions | await expect(args.onPress).toHaveBeenCalled() | Use storybook/test assertions |
| beforeEach hook | beforeEach: async ({ args }) => {} | Setup mocks before story renders |
| Play composition | await OtherStory.play?.(context) | Reuse setup across stories |
| Autodocs | tags: ['autodocs'] | Enable automatic documentation |
| Controls customization | argTypes: { variant: { control: 'select' } } | Configure control panel |
| Decorators | decorators: [withTheme] | Add wrappers or context providers |
| Parameters | parameters: { layout: 'centered' } | Configure addon behavior per story |
| Chromatic snapshot | parameters: { chromatic: { delay: 300 } } | Control visual regression captures |
| Disable snapshot | parameters: { chromatic: { disableSnapshot: true } } | Skip story in visual tests |
| A11y testing | await expect(button).toHaveAccessibleName() | Validate accessible labels |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
| Using args with multi-component layouts | Use render for complex compositions |
| Not awaiting userEvent methods | Always await: await userEvent.click(button) |
| Using within(canvasElement) manually | Destructure canvas from play context directly |
| Using getByTestId first | Prefer getByRole, getByLabelText, getByText |
| Missing default args at meta level | Add args to meta to prevent placeholder controls |
| Exposing non-serializable props | Disable className, ref, style in argTypes |
| Not composing play functions | Reuse setup with await BaseStory.play?.(context) |
| Forgetting to mock callbacks | Use fn() for event handlers: args: { onPress: fn() } |
| Missing accessible names for icon buttons | Add aria-label or use aria-labelledby |
| Not scoping queries for portal content | Use within(canvasElement.ownerDocument.body) |
| Using boolean && for conditional rendering | Use ternary in stories for consistent snapshots |
| Not waiting for animations | Wrap assertions in waitFor for async state |
Common Fixes
| Problem | Solution |
|---|---|
| Controls show placeholders | Add args at meta level with default values |
| Serialization error | Disable className, ref, style in argTypes |
| Portal element not found | Search body instead of canvas |
| Animation timing issues | Wrap assertions in waitFor |
| Multiple buttons found | Add { name: '...' } to getByRole |
| A11y test failing | Add label prop or aria-label |
Delegation
- Story structure review: Use
code-reviewerskill for CSF3 pattern validation - Accessibility audit: Use
Exploreagent to discover ARIA patterns across stories - Visual regression analysis: Use
Taskagent to investigate Chromatic failures
References
- Story writing patterns and CSF3 syntax
- Interaction tests with play functions
- Component documentation and controls
- Visual testing with Chromatic
Component Documentation
Autodocs
Enable automatic documentation generation with the autodocs tag:
const meta = {
component: Button,
tags: ['autodocs'],
title: 'Components/Button',
} satisfies Meta<typeof Button>;Storybook generates a documentation page with:
- Component description from JSDoc comments
- Props table from TypeScript types
- Interactive controls for all stories
- Live code examples
Component Description
Add JSDoc comments to your component:
/**
* Primary UI component for user interaction. Supports multiple variants,
* sizes, and states. Built on React Aria for accessibility.
*/
export function Button({
variant = 'default',
size = 'medium',
...props
}: ButtonProps) {
return <button {...props} />;
}The description appears at the top of the docs page.
Prop Descriptions
Document individual props:
type ButtonProps = {
/**
* Visual style variant
* @default 'default'
*/
variant?: 'default' | 'primary' | 'secondary' | 'destructive';
/**
* Button size affecting padding and font size
* @default 'medium'
*/
size?: 'small' | 'medium' | 'large';
/**
* Disables the button and prevents interactions
*/
disabled?: boolean;
/**
* Callback fired when the button is pressed
*/
onPress?: () => void;
};Descriptions appear in the props table.
ArgTypes Configuration
Control Types
Configure control types in meta:
const meta = {
argTypes: {
variant: {
control: 'select',
description: 'Visual style variant',
options: ['default', 'primary', 'secondary', 'destructive'],
},
size: {
control: 'radio',
options: ['small', 'medium', 'large'],
},
disabled: {
control: 'boolean',
},
children: {
control: 'text',
},
count: {
control: { type: 'number', min: 0, max: 10, step: 1 },
},
backgroundColor: {
control: 'color',
},
date: {
control: 'date',
},
},
component: Button,
} satisfies Meta<typeof Button>;Hide Props
Hide props from the controls and docs table:
const meta = {
argTypes: {
className: { table: { disable: true } },
ref: { table: { disable: true } },
style: { table: { disable: true } },
// Hide but keep in table
onPress: { control: false },
},
component: Button,
} satisfies Meta<typeof Button>;Hide non-serializable props like className, ref, and style to prevent errors.
Control Categories
Group controls into categories:
const meta = {
argTypes: {
variant: {
table: { category: 'Appearance' },
},
size: {
table: { category: 'Appearance' },
},
disabled: {
table: { category: 'State' },
},
onPress: {
table: { category: 'Events' },
},
},
component: Button,
} satisfies Meta<typeof Button>;Custom Mappings
Map control values to React elements:
const meta = {
argTypes: {
icon: {
control: 'select',
mapping: {
None: undefined,
Plus: <PlusIcon />,
Check: <CheckIcon />,
Close: <CloseIcon />,
},
options: ['None', 'Plus', 'Check', 'Close'],
},
},
component: Button,
} satisfies Meta<typeof Button>;Default Values
Set default values for controls:
const meta = {
args: {
children: 'Button',
disabled: false,
size: 'medium',
variant: 'default',
},
argTypes: {
variant: {
control: 'select',
options: ['default', 'primary', 'secondary'],
},
},
component: Button,
} satisfies Meta<typeof Button>;Default args prevent "Set boolean" placeholders in controls.
MDX Documentation
Create custom documentation pages with MDX:
import { Canvas, Controls, Description, Meta, Story } from '@storybook/blocks';
import * as ButtonStories from './button.stories';
<Meta of={ButtonStories} />
# Button
<Description of={ButtonStories} />
## Usage
Buttons trigger actions and events.
<Canvas of={ButtonStories.Primary} />
## Props
<Controls of={ButtonStories.Primary} />
## Variants
<Canvas of={ButtonStories.AllVariants} />
### Primary
Use for main call-to-action buttons.
<Story of={ButtonStories.Primary} />
### Secondary
Use for secondary actions.
<Story of={ButtonStories.Secondary} />
## Best Practices
- Use clear, action-oriented labels
- Provide accessible names for icon-only buttons
- Disable during async operations to prevent double-submissionSave as button.mdx and reference in stories:
const meta = {
component: Button,
parameters: {
docs: {
page: () => import('./button.mdx'),
},
},
} satisfies Meta<typeof Button>;Description Blocks
Component Description
import { Description } from '@storybook/blocks';
import * as Stories from './button.stories';
<Description of={Stories} />Pulls description from component JSDoc.
Story Description
export const Primary: Story = {
args: {
variant: 'primary',
},
parameters: {
docs: {
description: {
story: 'Primary buttons draw attention to main actions.',
},
},
},
};Appears above the story in docs.
Meta Description
const meta = {
component: Button,
parameters: {
docs: {
description: {
component: 'Buttons trigger actions when pressed.',
},
},
},
} satisfies Meta<typeof Button>;Overrides JSDoc component description.
Code Examples
Source Code Display
Show formatted source code:
import { Source } from '@storybook/blocks';
<Source
code={`
<Button variant="primary" size="large">
Primary Button
</Button>
`}
language="tsx"
/>Dynamic Source
Display story source:
import { Source } from '@storybook/blocks';
import * as Stories from './button.stories';
<Source of={Stories.Primary} />Hide Source
Hide source code for a story:
export const Internal: Story = {
parameters: {
docs: {
source: { code: null },
},
},
};Canvas Configuration
Configure layout, backgrounds, and viewport:
export const FullWidth: Story = {
parameters: {
layout: 'fullscreen', // or 'centered', 'padded'
backgrounds: { default: 'dark' },
viewport: { defaultViewport: 'mobile1' },
},
};Story Sorting
Sort stories in custom order:
const meta = {
component: Button,
parameters: {
docs: {
story: {
inline: true,
},
},
},
title: 'Components/Button',
} satisfies Meta<typeof Button>;
export const _01_Default: Story = { args: {} };
export const _02_Primary: Story = { args: { variant: 'primary' } };
export const _03_Secondary: Story = { args: { variant: 'secondary' } };Prefix story names with numbers for explicit ordering.
Subcomponents
Document related components together:
const meta = {
component: Card,
subcomponents: {
CardContent,
CardFooter,
CardHeader,
},
title: 'Components/Card',
} satisfies Meta<typeof Card>;Subcomponents appear in the props table dropdown.
Disable Docs
Hide story from docs page:
export const InternalOnly: Story = {
parameters: {
docs: { disable: true },
},
};Hide entire meta from docs:
const meta = {
component: Button,
tags: ['!autodocs'],
} satisfies Meta<typeof Button>;Interaction Tests
Play Function Basics
Play functions execute after a story renders, simulating user interactions and validating behavior.
Imports
import { expect, fn } from 'storybook/test';Storybook 9 provides canvas, userEvent, and step directly in the play function context. The storybook/test package (replacing @storybook/test) provides expect, fn, and other utilities.
Basic Pattern
import { type Meta, type StoryObj } from '@storybook/react';
import { expect, fn } from 'storybook/test';
import { Button } from './button';
const meta = {
component: Button,
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const ClickInteraction: Story = {
args: {
children: 'Click me',
onPress: fn(),
},
play: async ({ args, canvas, userEvent }) => {
const button = canvas.getByRole('button');
await userEvent.click(button);
await expect(args.onPress).toHaveBeenCalledTimes(1);
},
};Always await userEvent methods for proper logging in the Interactions panel.
Canvas Scoping
The canvas object is provided directly in the play context, scoped to the story's DOM.
export const FormInteraction: Story = {
play: async ({ canvas, userEvent }) => {
const emailInput = canvas.getByLabelText('Email');
const passwordInput = canvas.getByLabelText('Password');
const submitButton = canvas.getByRole('button', { name: 'Submit' });
await userEvent.type(emailInput, 'user@example.com');
await userEvent.type(passwordInput, 'password123');
await userEvent.click(submitButton);
},
};Portal Components
For components that render in portals (modals, tooltips, dropdowns), query from body using within:
import { expect, fn } from 'storybook/test';
import { within } from '@storybook/test';
export const DialogInteraction: Story = {
args: {
onOpenChange: fn(),
},
play: async ({ args, canvas, canvasElement, userEvent }) => {
const body = within(canvasElement.ownerDocument.body);
const trigger = canvas.getByRole('button', { name: 'Open Dialog' });
await userEvent.click(trigger);
const dialog = body.getByRole('dialog');
await expect(dialog).toBeInTheDocument();
const closeButton = within(dialog).getByRole('button', { name: 'Close' });
await userEvent.click(closeButton);
await waitFor(() => {
expect(args.onOpenChange).toHaveBeenCalledWith(false);
});
},
};Testing Library Queries
Query Priority
Use queries in this order:
1. getByRole - Buttons, inputs, links, headings 2. getByLabelText - Form fields with labels 3. getByPlaceholderText - Inputs without labels 4. getByText - Non-interactive text content 5. getByTestId - Last resort only
getByRole Examples
canvas.getByRole('button');
canvas.getByRole('button', { name: 'Submit' });
canvas.getByRole('button', { name: /submit/i });
canvas.getByRole('textbox', { name: 'Email' });
canvas.getByRole('checkbox', { name: 'Accept terms' });
canvas.getByRole('heading', { level: 1 });
canvas.getByRole('link', { name: 'Learn more' });Async Queries
Use findBy for elements that appear asynchronously:
const successMessage = await canvas.findByText('Form submitted');
await expect(successMessage).toBeVisible();Query Modifiers
canvas.getByRole('button');
canvas.getAllByRole('button');
canvas.queryByRole('button');
canvas.queryAllByRole('button');
canvas.findByRole('button');
canvas.findAllByRole('button');getBy- Throws if not found (use for elements that should exist)queryBy- Returns null if not found (use for conditional elements)findBy- Async, waits for element (use for elements that appear later)
User Interactions
Click Events
await userEvent.click(element);
await userEvent.dblClick(element);
await userEvent.tripleClick(element);Typing
await userEvent.type(input, 'text to type');
await userEvent.clear(input);
await userEvent.type(input, 'replace{Backspace}d text');Special keys:
await userEvent.type(input, 'text{Enter}');
await userEvent.type(input, '{Escape}');
await userEvent.type(input, '{Tab}');
await userEvent.type(input, '{Shift>}text{/Shift}');Keyboard Navigation
await userEvent.keyboard('{Tab}');
await userEvent.keyboard('{ArrowDown}');
await userEvent.keyboard('{Space}');
await userEvent.keyboard('[ShiftLeft>]A[/ShiftLeft]');Pointer Events
await userEvent.hover(element);
await userEvent.unhover(element);
await userEvent.pointer({ target: element, coords: { x: 10, y: 20 } });Form Controls
await userEvent.selectOptions(select, 'option-value');
await userEvent.selectOptions(select, ['multiple', 'options']);
await userEvent.upload(fileInput, file);Assertions
Element State
await expect(element).toBeInTheDocument();
await expect(element).toBeVisible();
await expect(element).not.toBeVisible();
await expect(element).toBeDisabled();
await expect(element).toBeEnabled();
await expect(element).toHaveFocus();Text Content
await expect(element).toHaveTextContent('text');
await expect(element).toHaveTextContent(/pattern/i);Attributes
await expect(element).toHaveAttribute('aria-expanded', 'true');
await expect(element).toHaveAttribute('disabled');
await expect(element).toHaveClass('active');
await expect(element).toHaveValue('value');Accessibility
await expect(element).toHaveAccessibleName('Button label');
await expect(element).toHaveAccessibleDescription('Helper text');Function Calls
await expect(args.onPress).toHaveBeenCalled();
await expect(args.onPress).toHaveBeenCalledTimes(2);
await expect(args.onPress).toHaveBeenCalledWith('arg1', 'arg2');
await expect(args.onPress).not.toHaveBeenCalled();Mocking Event Handlers
Use fn() from storybook/test to create mock functions:
export const WithCallbacks: Story = {
args: {
onChange: fn(),
onBlur: fn(),
onFocus: fn(),
},
play: async ({ args, canvas, userEvent }) => {
const input = canvas.getByRole('textbox');
await userEvent.click(input);
await expect(args.onFocus).toHaveBeenCalledTimes(1);
await userEvent.type(input, 'text');
await expect(args.onChange).toHaveBeenCalled();
await userEvent.tab();
await expect(args.onBlur).toHaveBeenCalledTimes(1);
},
};WaitFor Pattern
Use waitFor for animations, async state updates, or delayed assertions:
export const AsyncUpdate: Story = {
args: {
onSave: fn(),
},
play: async ({ args, canvas, userEvent }) => {
const button = canvas.getByRole('button', { name: 'Save' });
await userEvent.click(button);
await waitFor(
async () => {
await expect(args.onSave).toHaveBeenCalled();
},
{ timeout: 3000 },
);
const successMessage = await canvas.findByText('Saved successfully');
await expect(successMessage).toBeVisible();
},
};Play Function Composition
Reuse setup across stories:
export const FilledForm: Story = {
play: async ({ canvas, userEvent }) => {
await userEvent.type(canvas.getByLabelText('Email'), 'test@example.com');
await userEvent.type(canvas.getByLabelText('Password'), 'password123');
},
};
export const SubmittedForm: Story = {
args: {
onSubmit: fn(),
},
play: async (context) => {
await FilledForm.play?.(context);
const { args, canvas, userEvent } = context;
const submitButton = canvas.getByRole('button', { name: 'Submit' });
await userEvent.click(submitButton);
await expect(args.onSubmit).toHaveBeenCalledTimes(1);
},
};
export const ValidationError: Story = {
play: async (context) => {
await FilledForm.play?.(context);
const { canvas, userEvent } = context;
await userEvent.clear(canvas.getByLabelText('Email'));
await userEvent.tab();
const errorMessage = await canvas.findByText('Email is required');
await expect(errorMessage).toBeVisible();
},
};Always use optional chaining ?.() when calling composed play functions.
Step Function
Group related interactions for better debugging. The step helper is available directly in the play context:
export const MultiStepForm: Story = {
play: async ({ canvas, step, userEvent }) => {
await step('Fill personal information', async () => {
await userEvent.type(canvas.getByLabelText('Name'), 'John Doe');
await userEvent.type(canvas.getByLabelText('Email'), 'john@example.com');
});
await step('Select preferences', async () => {
await userEvent.click(canvas.getByLabelText('Marketing emails'));
await userEvent.selectOptions(canvas.getByLabelText('Country'), 'US');
});
await step('Submit form', async () => {
const submitButton = canvas.getByRole('button', { name: 'Submit' });
await userEvent.click(submitButton);
});
},
};Steps appear as collapsible groups in the Interactions panel.
beforeEach Hook
Set up mocks and preconditions before the story renders:
import { expect, fn } from 'storybook/test';
export const WithMockedData: Story = {
args: {
getUsers: fn(),
onSubmit: fn(),
},
beforeEach: async ({ args }) => {
args.getUsers.mockResolvedValue([
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
]);
},
play: async ({ args, canvas, userEvent }) => {
const usersList = canvas.getAllByRole('listitem');
await expect(usersList).toHaveLength(2);
const submitButton = canvas.getByRole('button', { name: 'Submit' });
await userEvent.click(submitButton);
await expect(args.onSubmit).toHaveBeenCalled();
},
};Debugging Failed Tests
Log Element State
play: async ({ canvas }) => {
console.log(canvas.getByRole('button').outerHTML);
canvas.debug();
canvas.debug(canvas.getByRole('button'));
};Check Element Visibility
const element = canvas.queryByRole('button');
if (!element) {
console.log('Button not found in DOM');
} else if (!element.offsetParent) {
console.log('Button exists but is not visible');
}Increase Timeout
export const SlowAnimation: Story = {
parameters: {
test: {
timeout: 10000,
},
},
play: async ({ canvasElement }) => {
await waitFor(() => {}, { timeout: 5000 });
},
};Accessibility Testing
Stories automatically run axe-core accessibility checks. Test specific accessible names in play functions:
export const IconButtonAccessibility: Story = {
args: {
'aria-label': 'Add new item',
children: <Icon name="plus" className="size-4" />,
size: 'icon',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button', { name: 'Add new item' });
await expect(button).toHaveAccessibleName('Add new item');
},
};Disabling A11y for Known Issues
export const KnownA11yIssue: Story = {
parameters: {
a11y: { disable: true },
},
};Story Writing
Component Story Format 3 (CSF3)
CSF3 uses object syntax with satisfies for type safety. Each story is an exported object with configuration.
Basic Story with Args
Use args for simple stories where you render a single component with different props.
import { type Meta, type StoryObj } from '@storybook/react';
import { Button } from './button';
const meta = {
component: Button,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
title: 'Components/Button',
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
children: 'Click me',
},
};
export const Primary: Story = {
args: {
children: 'Primary Button',
variant: 'primary',
},
};
export const Disabled: Story = {
args: {
children: 'Disabled',
disabled: true,
},
};Complex Story with Render
Use render when you need multiple components, wrappers, or complex layouts.
export const WithIcon: Story = {
render: (args) => (
<Button {...args}>
<Icon name="plus" className="size-4" />
Add Item
</Button>
),
};
export const ButtonGroup: Story = {
parameters: {
controls: { disable: true },
},
render: () => (
<div className="flex gap-2">
<Button variant="primary">Save</Button>
<Button variant="secondary">Cancel</Button>
<Button variant="destructive">Delete</Button>
</div>
),
};Disable controls for showcase stories that demonstrate all variants.
Meta Configuration
Default Args
Define default args at the meta level to prevent "Set boolean" placeholders in controls.
const meta = {
args: {
children: 'Button',
disabled: false,
size: 'medium',
variant: 'default',
},
component: Button,
} satisfies Meta<typeof Button>;ArgTypes
Configure controls and hide non-serializable props.
const meta = {
argTypes: {
className: { table: { disable: true } },
ref: { table: { disable: true } },
style: { table: { disable: true } },
variant: {
control: 'select',
options: ['default', 'primary', 'secondary', 'destructive'],
},
size: {
control: 'radio',
options: ['small', 'medium', 'large'],
},
icon: {
control: 'select',
mapping: {
None: undefined,
Plus: <Icon name="plus" />,
Check: <Icon name="check" />,
},
options: ['None', 'Plus', 'Check'],
},
},
component: Button,
} satisfies Meta<typeof Button>;Hide React Aria props like className, ref, and style to prevent serialization errors.
Parameters
Configure addon behavior per story or meta.
const meta = {
component: Dialog,
parameters: {
layout: 'centered',
backgrounds: {
default: 'light',
values: [
{ name: 'light', value: '#ffffff' },
{ name: 'dark', value: '#1a1a1a' },
],
},
viewport: {
defaultViewport: 'mobile1',
},
},
} satisfies Meta<typeof Dialog>;Override parameters in individual stories:
export const FullWidth: Story = {
parameters: {
layout: 'fullscreen',
},
render: () => <Header />,
};Decorators
Decorators wrap stories with providers or layout containers.
Theme Provider Decorator
import { type Decorator } from '@storybook/react';
import { ThemeProvider } from './theme-provider';
const withTheme: Decorator = (Story) => (
<ThemeProvider theme="light">
<Story />
</ThemeProvider>
);
const meta = {
component: Button,
decorators: [withTheme],
} satisfies Meta<typeof Button>;Layout Decorator
const withPadding: Decorator = (Story) => (
<div className="p-8">
<Story />
</div>
);
export const CardExample: Story = {
decorators: [withPadding],
render: () => <Card>Content</Card>,
};Router Decorator
import { type Decorator } from '@storybook/react';
import { MemoryRouter } from 'react-router-dom';
const withRouter: Decorator = (Story) => (
<MemoryRouter initialEntries={['/']}>
<Story />
</MemoryRouter>
);
const meta = {
component: Navigation,
decorators: [withRouter],
} satisfies Meta<typeof Navigation>;Story Organization
Naming Stories
Use descriptive names that explain the scenario:
export const Default: Story = { args: {} };
export const WithLongText: Story = {
args: {
children: 'This is a very long button label that might wrap',
},
};
export const LoadingState: Story = {
args: {
children: 'Loading...',
loading: true,
},
};
export const ErrorState: Story = {
args: {
children: 'Retry',
error: true,
},
};Grouping with Title
Use slashes to create hierarchy:
const meta = {
component: Button,
title: 'Components/Forms/Button',
} satisfies Meta<typeof Button>;This creates: Components → Forms → Button
Args Composition
Extend args from other stories:
export const Primary: Story = {
args: {
variant: 'primary',
},
};
export const PrimaryDisabled: Story = {
args: {
...Primary.args,
disabled: true,
},
};Conditional Rendering
Always use ternary operator instead of && to prevent inconsistent snapshots:
export const ConditionalIcon: Story = {
render: (args) => (
<Button {...args}>
{args.showIcon ? <Icon name="plus" /> : null}
{args.children}
</Button>
),
};Avoid: {args.showIcon && <Icon />} because false values appear differently in snapshots.
Multiple Components
Group related components in one file:
import { type Meta, type StoryObj } from '@storybook/react';
import { Button } from './button';
import { ButtonGroup } from './button-group';
const meta = {
component: Button,
title: 'Components/Button',
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Single: Story = {
args: { children: 'Button' },
};
const groupMeta = {
component: ButtonGroup,
title: 'Components/ButtonGroup',
} satisfies Meta<typeof ButtonGroup>;
export { groupMeta as ButtonGroupMeta };Story-Level TypeScript
Type args for custom properties:
type CustomStory = StoryObj<typeof meta> & {
args: {
customProp?: string;
};
};
export const WithCustomProp: CustomStory = {
args: {
customProp: 'value',
},
render: (args) => <Component {...args} />,
};Tags
Control story behavior with tags:
const meta = {
component: Button,
tags: ['autodocs', 'test-only'],
} satisfies Meta<typeof Button>;
export const Example: Story = {
tags: ['!dev'],
};autodocs- Generate documentation automatically!dev- Hide in dev mode!test- Exclude from test runnertest-only- Only show in test runner
Visual Testing
Chromatic Overview
Chromatic captures pixel-perfect snapshots of stories and detects visual regressions. Configure snapshots using the chromatic parameter.
Basic Setup
Configure global settings in .storybook/preview.ts:
import { type Preview } from '@storybook/react';
const preview: Preview = {
parameters: {
chromatic: {
delay: 300,
diffThreshold: 0.1,
modes: {
dark: {
theme: 'dark',
},
light: {
theme: 'light',
},
},
},
},
};
export default preview;Per-Story Configuration
Override settings for individual stories:
export const AnimatedComponent: Story = {
parameters: {
chromatic: {
delay: 500,
pauseAnimationAtEnd: true,
},
},
};Snapshot Modes
Capture stories in multiple themes or configurations:
const preview: Preview = {
parameters: {
chromatic: {
modes: {
dark: { theme: 'dark' },
light: { theme: 'light' },
},
},
},
};Override per story:
export const LightOnly: Story = {
parameters: {
chromatic: { modes: { light: { theme: 'light' } } },
},
};Viewports
Capture stories at different screen sizes:
export const ResponsiveComponent: Story = {
parameters: {
chromatic: {
viewports: [320, 768, 1200],
},
},
};Animation Handling
Wait for animations or pause them:
export const WithAnimation: Story = {
parameters: {
chromatic: {
delay: 1000,
pauseAnimationAtEnd: true,
},
},
};Ignoring Dynamic Content
Ignore Selectors
Ignore elements that change on every render:
export const WithDynamicContent: Story = {
parameters: {
chromatic: {
ignoreSelectors: ['.timestamp', '.random-avatar', '.live-data'],
},
},
};Ignore via ClassName
Add class to elements to ignore:
export const WithClock: Story = {
render: () => (
<div>
<h1>Dashboard</h1>
<div className="chromatic-ignore">
<Clock />
</div>
</div>
),
};Ignore via Data Attribute
export const WithVideo: Story = {
render: () => <video data-chromatic="ignore" src="video.mp4" />,
};Snapshot Control
Disable Snapshot
Skip story in visual tests:
export const InteractiveOnly: Story = {
parameters: {
chromatic: {
disableSnapshot: true,
},
},
};Use for stories with interaction tests but no visual regression value.
Force Re-Snapshot
Force new baseline snapshot:
export const Updated: Story = {
parameters: {
chromatic: {
forcedReRender: true,
},
},
};Diff Threshold
Global Threshold
Set sensitivity for all stories:
const preview: Preview = {
parameters: {
chromatic: {
diffThreshold: 0.2,
},
},
};0.0= Pixel-perfect (very sensitive)0.5= Moderate differences allowed1.0= Very lenient
Per-Story Threshold
export const SubtleGradient: Story = {
parameters: {
chromatic: {
diffThreshold: 0.3,
},
},
};Story Isolation
Keep stories independent using args instead of play functions for initial state:
export const OpenState: Story = {
args: { defaultOpen: true },
};
export const ClosedState: Story = {
args: { defaultOpen: false },
};CI Configuration
Run Chromatic in GitHub Actions:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- run: pnpm install
- run: pnpm chromatic --project-token=${{ secrets.CHROMATIC_PROJECT_TOKEN }}Flags: --exit-zero-on-changes (prevent CI failure), --auto-accept-changes main (auto-accept on main), --only-changed (TurboSnap optimization).
Interaction Tests with Snapshots
Capture snapshots after play function completes:
export const AfterInteraction: Story = {
parameters: {
chromatic: {
delay: 500,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button');
await userEvent.click(button);
// Wait for transition
await new Promise((resolve) => setTimeout(resolve, 300));
},
};Chromatic captures the final state after play function execution.
Debugging
Preview snapshots locally:
pnpm build-storybook
npx http-server storybook-staticCheck diagnostics:
pnpm chromatic --diagnosticsCommon Patterns
Modal Snapshots
export const OpenModal: Story = {
args: {
defaultOpen: true,
},
parameters: {
chromatic: {
delay: 300,
},
},
};Tooltip Snapshots
export const TooltipVisible: Story = {
parameters: {
chromatic: {
delay: 500,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const trigger = canvas.getByRole('button');
await userEvent.hover(trigger);
await new Promise((resolve) => setTimeout(resolve, 200));
},
};Form States
export const FilledForm: Story = {
parameters: {
chromatic: {
disableSnapshot: false,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText('Email'), 'user@example.com');
await userEvent.type(canvas.getByLabelText('Password'), 'password123');
// Wait for validation
await new Promise((resolve) => setTimeout(resolve, 100));
},
};Error States
export const ValidationError: Story = {
args: {
error: 'Email is required',
touched: true,
},
parameters: {
chromatic: {
delay: 100,
},
},
};Prefer args over play functions for static error states.