Storybook 8 Guide: Stories, Args, Docs, Visual Testing
Everything you need to ship Storybook 8 — stories, args, autodocs, play functions, and visual regression testing in one practical guide.
Why Storybook 8 Is Actually Worth Upgrading To
Storybook 8 shipped in early 2024 and the jump from 7 is bigger than the version bump suggests. The build tooling got overhauled — Vite is now the default bundler instead of Webpack, and the result is story reloads that are genuinely fast. We're talking cold starts under 2 seconds on a mid-sized component library.
Honestly, if you're still on Storybook 6, you're missing two full generations of the args system, the entire autodocs workflow, and native play functions. Each of those alone would justify the migration. Together, they change how your team writes, reviews, and tests components.
The other big thing in v8 is the story format. CSF3 (Component Story Format 3) became the de facto standard, and the boilerplate got cut significantly. You don't need to re-export every story function with explicit storyName anymore — the story object itself handles naming. If you've been putting off the migration because of the refactor cost, the codemods in v8 cover about 90% of it automatically.
Worth noting: Storybook 8 also dropped IE11 support entirely and leaned hard into native ESM. That might sound like a footnote, but if you've got a legacy bundle config, you'll feel it. Check your .storybook/main.ts carefully before you upgrade.
Writing Stories with CSF3 — The Right Way
The core mental model hasn't changed: a story is a named export from a .stories.tsx file that renders your component in a specific state. What's changed in CSF3 is how concise that looks and how much the TypeScript inference handles for you.
Here's a basic CSF3 story for a Button component:
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
component: Button,
args: {
label: 'Click me',
variant: 'primary',
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {};
export const Disabled: Story = {
args: {
disabled: true,
label: 'Unavailable',
},
};Notice that Primary is literally an empty object. It inherits everything from the meta.args. You only override what's different per story. That's the composable args pattern and it pays off massively in larger design systems where you've got 30+ stories per component.
One more thing — name your stories with spaces if you want readable output. Storybook 8 will automatically convert PrimaryLarge to "Primary Large" in the sidebar, but being explicit with name: 'Primary / Large' gives you path-style nesting without extra folder config.
Look, CSF3 isn't just a style preference. The args system feeding into play functions and visual tests later in the pipeline only works cleanly when you're using typed args rather than render functions with hardcoded JSX. Get this right at the start and everything downstream is easier.
Args, ArgTypes, and Controls — Making Stories Interactive
Args are the props your component receives in the story. ArgTypes are metadata about those args — what kind of control to show in the UI, what values are valid, how it should be labeled. You don't always need to define argTypes manually because Storybook 8 can infer them from TypeScript prop types, but you'll want to customize them for anything non-trivial.
const meta: Meta<typeof Card> = {
component: Card,
argTypes: {
variant: {
control: 'select',
options: ['default', 'glass', 'neo'],
description: 'Visual style of the card',
table: {
defaultValue: { summary: 'default' },
},
},
blurAmount: {
control: { type: 'range', min: 0, max: 40, step: 2 },
description: 'Backdrop blur in px — relevant for glass variant',
},
},
};The range control type is criminally underused. For anything with a numerical range — blur amounts, border radii, opacity — it makes your Storybook feel like a live design tool rather than a docs page. If you're building glassmorphism components, a range slider on the blur value from 0 to 40px lets designers iterate without touching code.
In practice, you'll hit a wall when your component takes complex object props. Storybook doesn't auto-generate controls for nested objects well. The workaround is to flatten the args at the story level and reconstruct the object inside the story's render function. Not pretty, but it works and your colleagues won't hate you for it.
Quick aside: argTypes also control what shows up in the auto-generated docs table. If you add a description and a table.defaultValue, the Docs tab becomes genuinely useful for onboarding new developers — not just a placeholder.
Autodocs — Getting Useful Documentation Without Writing It
Storybook 8's autodocs feature generates a full documentation page for each component from your stories and JSDoc comments. You opt in per-component or globally. The global setting in .storybook/main.ts looks like this:
// .storybook/main.ts
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(ts|tsx)'],
addons: ['@storybook/addon-docs'],
docs: {
autodocs: 'tag', // or true for all components
},
};With 'tag', you get autodocs only on stories that include tags: ['autodocs'] in their meta. That's the sane default — you don't want autogenerated docs pages for every internal helper component cluttering the sidebar.
The generated page pulls your component description from the JSDoc above the component function, each arg's description from argTypes, and renders all your stories inline with their controls. What you end up with is a live, interactive docs page that's always in sync with the actual component code. No separate Confluence page to maintain. No out-of-date prop tables.
That said, autodocs won't write your narrative prose. Add MDX blocks if you need to explain design decisions or usage guidelines. You can mix autodocs with custom MDX in the same file, which gives you the best of both worlds — generated API reference plus human-written context.
Play Functions — Testing Interactions Without a Separate Test File
Play functions are where Storybook 8 gets interesting for testing. A play function runs after a story renders and lets you simulate user interactions — clicks, typing, focus events — using Testing Library queries. The result is a story that doubles as an integration test.
import { userEvent, within } from '@storybook/test';
export const SubmitForm: Story = {
args: {
onSubmit: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.type(
canvas.getByLabelText('Email'),
'test@example.com'
);
await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
expect(args.onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
});
},
};Notice the fn() helper from @storybook/test — that's a Storybook-native spy that shows call history in the Actions panel. In Storybook 8, @storybook/test is a thin wrapper around Vitest's expect and mock APIs, so if your team already uses Vitest, the mental model transfers directly.
Play functions run in the browser, which means they catch real DOM quirks that jsdom-based unit tests can miss. That's not a knock on unit tests — they're faster and better for logic. But for components with complex focus management, portal rendering, or CSS-driven interactions, play functions in Storybook catch regressions that nothing else will.
How does this connect to a design system? If you're building a shared component library — whether it's custom or extending something from Empire UI — play functions let you validate interactive behavior at the story level before anyone publishes a new version. That's a real quality gate, not just documentation.
Visual Regression Testing with Storybook
Visual regression testing compares screenshots of your stories against a baseline and flags pixel-level diffs. Storybook 8 integrates with Chromatic (the first-party service), but you can also wire up Playwright or Percy if you're self-hosting.
The Chromatic setup is brutally simple — run npx chromatic --project-token=<your-token> in CI and it handles the rest. Each push uploads story snapshots, diffs them against the accepted baseline, and fails the build if anything changed outside a configured threshold. For a design system with glassmorphism components or anything with CSS blur and gradient values, catching unexpected rendering changes is worth the CI overhead.
If you'd rather not pay for Chromatic, Playwright has a built-in toHaveScreenshot() matcher that works against a local story server. The setup takes more config but gives you full control:
// playwright.config.ts
export default defineConfig({
testDir: './tests',
use: {
baseURL: 'http://localhost:6006',
},
});
// Button.visual.spec.ts
test('Button primary state', async ({ page }) => {
await page.goto('/iframe.html?id=button--primary&viewMode=story');
await expect(page).toHaveScreenshot('button-primary.png', {
threshold: 0.02,
});
});Worth noting: visual tests are brittle if you run them across different OS environments. A 1px anti-aliasing difference between macOS and Linux will fail your test even when nothing's wrong. Lock your visual test runner to a Docker container with a fixed font rendering setup if you're doing this in CI.
In practice, the most value comes from testing your most-used components — buttons, inputs, cards, modals — with visual regression, and using play functions for everything else. You don't need both on every story. That combination gives you fast feedback on logic and reliable catches on visual drift.
Structuring Storybook for a Real Design System
A flat list of stories falls apart at scale. By the time you've got 50+ components, you need a clear taxonomy. The standard approach is to mirror your component folder structure in your story paths and use the title field in meta to create sidebar categories.
src/
components/
primitives/
Button/
Button.tsx
Button.stories.tsx // title: 'Primitives/Button'
patterns/
Card/
Card.tsx
Card.stories.tsx // title: 'Patterns/Card'
layouts/
Grid/
Grid.tsx
Grid.stories.tsx // title: 'Layouts/Grid'Separating primitives, patterns, and layouts maps to how designers think about components, which makes Storybook actually useful for non-engineers on your team. That's not just nice to have — in 2025 and into 2026, design-engineer collaboration is what separates teams that ship fast from teams that spend two weeks in Figma review.
Add a dedicated Introduction story at the root that links out to your design tokens, changelog, and related tools. Something as simple as an MDX page pointing designers to your gradient generator or box shadow generator makes Storybook a genuine design system hub rather than a developer-only artifact.
One more thing — set up @storybook/addon-a11y from day one. The accessibility tab runs axe-core on every story and surfaces contrast failures, missing ARIA labels, and invalid DOM structure. It won't catch everything, but it catches the obvious stuff automatically so your code review doesn't have to.
FAQ
Storybook 8 defaults to Vite instead of Webpack, ships CSF3 as the standard story format, and unifies the testing utilities under @storybook/test (a Vitest wrapper). Build times are significantly faster and the args system is more composable.
No. Play functions test interactive behavior in a real browser DOM and are great for user flows. Unit tests are faster and better for pure logic. Use both — they cover different failure modes.
No. Chromatic is the first-party option and the easiest to set up, but Playwright's toHaveScreenshot() works against any running Storybook instance. Percy and Argos are also valid alternatives.
Run npx storybook@latest init from your project root — it detects Next.js and installs the right framework package (@storybook/nextjs) automatically. You'll need Node 18+ and should expect it to add around 15 packages.