Storybook tutorial: build and test UI components in isolation
By Flavio Copes
Use Storybook with React to develop components in isolation, model loading and error states, test interactions and accessibility, and run CI.
An application rarely shows a component in only one state.
A user card might be:
- loading
- complete
- missing an avatar
- showing a long name
- displaying an error
Those states can be hard to reach by clicking through the whole application.
Storybook renders components in isolation. A story describes one useful state, using normal component props.
In this tutorial we’ll add Storybook to a React project and build a small UserCard through its stories.
Then we’ll add interaction and accessibility tests and run them in CI.
Install Storybook
Inside an existing React project that uses Vite, run:
npm create storybook@latest
The initializer detects the framework, installs packages, creates .storybook, and adds example stories.
Start it:
npm run storybook
Storybook opens separately from the application. The sidebar lists stories, and the canvas renders the selected component.
Build the component
Create src/components/UserCard.jsx:
export function UserCard({
name,
role,
avatarUrl,
loading = false,
error = '',
onMessage,
}) {
if (loading) {
return (
<article aria-busy="true" className="user-card">
<p>Loading user...</p>
</article>
)
}
if (error) {
return (
<article className="user-card" role="alert">
<p>{error}</p>
</article>
)
}
return (
<article className="user-card">
{avatarUrl ? (
<img src={avatarUrl} alt="" width="64" height="64" />
) : (
<span aria-hidden="true" className="user-card__placeholder">
{name.slice(0, 1)}
</span>
)}
<div>
<h2>{name}</h2>
<p>{role}</p>
<button type="button" onClick={() => onMessage(name)}>
Message {name}
</button>
</div>
</article>
)
}
A story should use the same component the application imports. Do not build a special Storybook-only copy.
Write the first story
Create src/components/UserCard.stories.jsx:
import { fn } from 'storybook/test'
import { UserCard } from './UserCard'
const meta = {
title: 'People/UserCard',
component: UserCard,
tags: ['autodocs'],
args: {
onMessage: fn(),
},
}
export default meta
export const Default = {
args: {
name: 'Ada Lovelace',
role: 'Mathematician',
avatarUrl: 'https://i.pravatar.cc/128?img=47',
},
}
The default export contains component metadata.
The named export is a story. args are the props Storybook passes to the component.
The fn() spy lets Storybook record button calls and lets our tests assert on them.
Model every important state
Add more stories:
export const WithoutAvatar = {
args: {
name: 'Grace Hopper',
role: 'Computer scientist',
avatarUrl: '',
},
}
export const Loading = {
args: {
loading: true,
},
}
export const Error = {
args: {
error: 'Could not load this user',
},
}
export const LongContent = {
args: {
name: 'A very long name that must not break the card layout',
role: 'Principal engineer working across several product teams',
avatarUrl: '',
},
}
This is where Storybook changes how we build UI.
Instead of waiting for the API to fail, we select the Error story. Instead of changing database data to produce a long name, we edit one story.
A story is useful when it represents a state we want to see again.
Use controls
Storybook generates controls from args. Open the Controls panel and edit name, role, or loading.
Add explicit control information when inference is not enough:
const meta = {
title: 'People/UserCard',
component: UserCard,
tags: ['autodocs'],
argTypes: {
role: {
control: 'text',
description: 'The role shown below the name',
},
avatarUrl: {
control: 'text',
},
},
args: {
onMessage: fn(),
},
}
Controls are excellent for exploration. Named stories are still important because they preserve reviewed states and can run as tests.
Add a decorator
Components often depend on layout or context.
A decorator wraps a story:
const meta = {
// ...
decorators: [
(Story) => (
<div style={{ maxWidth: 420, padding: 24 }}>
<Story />
</div>
),
],
}
Global decorators belong in .storybook/preview.js:
import '../src/index.css'
export default {
decorators: [
(Story) => (
<div className="storybook-page">
<Story />
</div>
),
],
}
Use the same providers and global CSS the app needs. Avoid recreating the whole application shell around every small component.
Write an interaction test
A renderable story is already a basic smoke test: the component must render without throwing.
For behavior, add a play function:
import { expect, fn } from 'storybook/test'
export const SendsMessage = {
args: {
name: 'Ada Lovelace',
role: 'Mathematician',
avatarUrl: '',
},
play: async ({ args, canvas, userEvent }) => {
const button = canvas.getByRole('button', {
name: 'Message Ada Lovelace',
})
await userEvent.click(button)
await expect(args.onMessage).toHaveBeenCalledWith(
'Ada Lovelace'
)
},
}
The play function runs after the story renders.
canvas provides Testing Library queries scoped to this story. Query by role and accessible name, as a person using assistive technology would find the control.
The Interactions panel shows each step and makes failures easier to debug.
Test keyboard behavior
A button already supports keyboard activation because we used a real <button>.
We can preserve that behavior with a story:
export const KeyboardMessage = {
args: {
name: 'Grace Hopper',
role: 'Computer scientist',
avatarUrl: '',
},
play: async ({ args, canvas, userEvent }) => {
await userEvent.tab()
const button = canvas.getByRole('button', {
name: 'Message Grace Hopper',
})
await expect(button).toHaveFocus()
await userEvent.keyboard('{Enter}')
await expect(args.onMessage).toHaveBeenCalled()
},
}
If the story’s wrapper contains other focusable elements, tab the number of times the real order requires. Do not force focus in the test and hide a broken tab order.
Add accessibility checks
Install the accessibility addon if the initializer did not include it:
npx storybook add @storybook/addon-a11y
The accessibility panel checks the rendered story with automated rules.
It can catch:
- insufficient color contrast
- missing form labels
- invalid ARIA attributes
- some heading and landmark problems
Automated checks cannot prove that a component is accessible. Test keyboard use and screen-reader behavior manually too.
Our decorative avatar uses alt="", while the person’s name appears as real text. Repeating the same name in the image alternative would add noise.
View mobile and dark states
Storybook’s viewport tools let us render a story at phone-sized widths.
Preserve a narrow state as a story parameter:
export const Mobile = {
args: {
name: 'Ada Lovelace',
role: 'Mathematician',
avatarUrl: '',
},
parameters: {
viewport: {
defaultViewport: 'mobile1',
},
},
}
Themes can be handled with a decorator that adds the same class or data attribute the application uses.
Do not make a separate dark component. Theme the same component.
Mock data at the network boundary
For a component that fetches data, stories should not depend on a live development API.
Use Storybook’s supported network mocking setup, commonly Mock Service Worker, to describe:
- success
- empty response
- slow response
- server error
The component still calls fetch(). The story controls the response at the network boundary.
This is more realistic than adding a fakeError prop that does not exist in production.
Run stories as tests
For a Vite-based Storybook, add the Vitest addon:
npx storybook add @storybook/addon-vitest
Storybook Test turns stories into browser tests. Render tests, play functions, and configured accessibility checks can run from the Storybook UI and from the command line.
Use the scripts generated by the addon. A typical CI step is:
- name: Run Storybook tests
run: npm run test-storybook
Storybook’s current test tooling uses Vitest browser mode and Playwright to render stories. Use a CI image with the required browser installed, or install it in the workflow.
Also build the static Storybook:
npm run build-storybook
This catches missing assets, invalid imports, and configuration that worked only in the development server.
What deserves a story?
Do not create stories for random prop combinations.
Create them for:
- normal states
- loading, empty, and error states
- important variants
- boundary content
- responsive layouts
- permission or role differences
- interaction flows
- bugs that should never return
When a UI bug is reported, first reproduce it as a story. Then fix the component and keep the story as a regression test.
Keep stories close to the component
A practical structure is:
src/components/UserCard/
UserCard.jsx
UserCard.css
UserCard.stories.jsx
UserCard.test.jsx
Stories are part of the component’s documentation and test surface, not marketing screenshots stored elsewhere.
Review them when the component API changes. A story that no longer represents a real application state is misleading even if it still renders.
We now have a small component workshop containing normal, loading, error, long-content, keyboard, and mobile states. The stories also test rendering and interaction behavior.
The main benefit of Storybook is not the sidebar. It is making hidden UI states cheap to create, inspect, discuss, and test before they surprise us inside the full application.
Related posts about react: