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
This tutorial uses Storybook 10.5.
You need Node.js 20 or newer, npm 10 or newer, and Vite 5 or newer.
Inside an existing React project that uses Vite, run:
npm create [email protected]
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 this structure:
src/
assets/
ada-lovelace.jpg
components/
UserCard/
UserCard.css
UserCard.jsx
UserCard.stories.jsx
Use a small local image for ada-lovelace.jpg. Keeping story assets local makes stories work without an internet connection.
Create src/components/UserCard/UserCard.jsx:
import './UserCard.css'
export function UserCard({
name,
role,
avatarUrl,
headingTag = 'h2',
loading = false,
error = '',
onMessage,
}) {
const Heading = headingTag
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>
<Heading className="user-card__name">{name}</Heading>
<p>{role}</p>
<button type="button" onClick={() => onMessage(name)}>
Message {name}
</button>
</div>
</article>
)
}
The component accepts the heading element as a prop. A reusable card cannot know whether its name should be an h2, h3, or another level in the page that uses it.
Now create src/components/UserCard/UserCard.css:
.user-card {
display: flex;
gap: 16px;
align-items: flex-start;
padding: 16px;
color: #171717;
background: #fff;
border: 1px solid #d4d4d4;
}
.user-card img,
.user-card__placeholder {
flex: 0 0 64px;
width: 64px;
height: 64px;
}
.user-card__placeholder {
display: grid;
place-items: center;
background: #e5e5e5;
}
.user-card__name {
margin: 0;
overflow-wrap: anywhere;
}
.user-card p {
overflow-wrap: anywhere;
}
[data-theme='dark'] .user-card {
color: #fafafa;
background: #171717;
border-color: #525252;
}
[data-theme='dark'] .user-card__placeholder {
background: #404040;
}
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/UserCard.stories.jsx:
import { fn } from 'storybook/test'
import adaAvatar from '../../assets/ada-lovelace.jpg'
import { UserCard } from './UserCard'
const meta = {
title: 'People/UserCard',
component: UserCard,
tags: ['autodocs'],
argTypes: {
headingTag: {
control: 'select',
options: ['h1', 'h2', 'h3', 'h4'],
},
role: {
control: 'text',
description: 'The role shown below the name',
},
avatarUrl: {
control: 'text',
},
},
args: {
name: 'Ada Lovelace',
role: 'Mathematician',
avatarUrl: '',
headingTag: 'h1',
loading: false,
error: '',
onMessage: fn(),
},
}
export default meta
export const Default = {
args: {
avatarUrl: adaAvatar,
},
}
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.
The meta also provides valid default props. Every story starts from a complete card, then overrides the state it needs.
The card is the top-level content in this isolated example, so its story uses an h1. When you use the card in the application, set headingTag to match the surrounding page.
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.
The argTypes in our meta set the heading selector and add a description for role. Storybook infers the remaining controls from the component and its args.
Try the controls on the Loading story. You can turn loading off without breaking the component because the meta provides a complete set of default args.
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:
decorators: [
(Story) => (
<div style={{ maxWidth: 420, padding: 24 }}>
<Story />
</div>
),
],
Add this decorators property to the existing meta object. It applies the wrapper to every UserCard story.
Global decorators belong in .storybook/preview.jsx:
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.
First, change the existing import at the top of the story file:
import { expect, fn } from 'storybook/test'
Then add this story:
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.
To make accessibility violations fail our tests, add this property to the existing meta object:
parameters: {
a11y: {
test: 'error',
},
},
The error setting runs accessibility checks with the Vitest addon and fails the test when it finds a violation.
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 with a story global:
export const Mobile = {
args: {
name: 'Ada Lovelace',
role: 'Mathematician',
avatarUrl: '',
},
globals: {
viewport: {
value: 'mobile1',
isRotated: false,
},
},
}
Themes can be handled with a decorator that adds the same class or data attribute the application uses. Our CSS uses data-theme="dark", so add this story:
export const Dark = {
decorators: [
(Story) => (
<div data-theme="dark" style={{ padding: 24 }}>
<Story />
</div>
),
],
}
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 GitHub Actions job is:
jobs:
test-storybook:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run test-storybook
Storybook’s current test tooling uses Vitest browser mode and Playwright to render stories. The browser installation step makes Chromium available on the CI machine.
Because we set a11y.test to error, this command runs render tests, play functions, and accessibility checks.
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/
assets/
ada-lovelace.jpg
components/
UserCard/
UserCard.css
UserCard.jsx
UserCard.stories.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, mobile, and dark states. The stories also test rendering, interaction behavior, and accessibility.
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.