Component-Driven Development with Storybook

21. February, 2026 13 min read Develop

The button you can never get to

Every frontend project eventually grows a component you cannot look at. It only renders when a request fails, or when the user has no permissions, or when the list comes back empty on page four of the results. To see it you log in as the right user, click through five screens and then hand-edit the network response in devtools. Storybook exists so you can just open it instead.

The last time Storybook turned up here it was a supporting act, one piece of a screenshot testing setup alongside Jest and Puppeteer. It has grown a lot since then. What used to be a component explorer is now a second build of your UI that you can point tests, screenshots and accessibility checks at. That growth is mostly good and partly annoying, and I’ll get to the annoying part at the end.

This post is about getting it running on a Next.js project and the parts of it I actually use. For the wider picture of how component testing fits together, see testing strategies in React.

What a story is

A story is one rendered example of a component with a specific set of props. That’s the whole idea. Button gets a Default story, a Destructive story, a Loading story, and each one shows up as its own entry in the sidebar.

What you gain from that is mostly boring and mostly valuable:

  • You can build a component before the screen it belongs to exists, and before the API that feeds it exists.
  • The states nobody looks at, empty, loading, error, “user has a 40-character name”, get a permanent home instead of being checked once during development and then forgotten.
  • A published Storybook is something a designer can open in a browser without cloning the repository or running a dev server.
  • Stories are importable, so the same file that documents a state can drive a test of that state.

The last one is the reason I keep coming back. Test fixtures and design documentation are the same data, and Storybook is the only tool I’ve used that treats them that way.

Getting it running with Next.js

The installer detects your framework and writes the config for you:

npm create storybook@latest

(The older npx storybook@latest init still works and does the same job. The create form is what the docs point at now.)

You’ll be asked to pick a builder: Webpack via @storybook/nextjs, or Vite via @storybook/nextjs-vite. Take Vite. The builds are faster and, more to the point, the Vitest integration that powers everything in the testing sections below only works on the Vite framework. Picking Webpack here means opting out of half the tool.

The installer creates a .storybook directory, drops a few example stories in and adds scripts to package.json. Next.js specifics are handled for you: next/image, next/font, next/navigation, CSS Modules and the path aliases from your tsconfig.json all work without configuration. That sounds like a small thing until you’ve tried to render a Next.js component in a plain React Storybook and spent an afternoon stubbing out the router.

For a Tailwind project, import your stylesheet in the preview file so every story gets the same base styles as the app:

// .storybook/preview.ts
import '../src/globals.css';

const preview = {
  parameters: {
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
  },
};

export default preview;

Those matchers are worth setting up on day one. Any prop whose name ends in color gets a colour picker, anything ending in Date gets a date picker, across every story in the project. It saves a lot of repeated argTypes boilerplate later.

shadcn/ui components need nothing special. They’re ordinary React components with Tailwind classes on them, so as long as the CSS variables from globals.css are loaded they render exactly as they do in the app.

Writing stories

Stories use Component Story Format, which is a fancy name for “an ES module with a default export and some named exports”. The default export describes the component, each named export is a story.

// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { Button } from './Button';

const meta = {
  component: Button,
  tags: ['autodocs'],
  argTypes: {
    variant: {
      options: ['default', 'destructive', 'outline', 'ghost'],
      control: { type: 'select' },
    },
  },
} satisfies Meta<typeof Button>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
  args: {
    children: 'Click me',
    variant: 'default',
  },
};

export const Destructive: Story = {
  args: {
    children: 'Delete',
    variant: 'destructive',
  },
};

export const Loading: Story = {
  args: {
    children: 'Saving...',
    disabled: true,
  },
};

Note the import path. In Storybook 9 the types come from the framework package, @storybook/nextjs-vite, not from @storybook/react. Older tutorials and half the answers on Stack Overflow still show the renderer package, and it will look like it works until it doesn’t.

args maps straight onto props, so anything you can pass to the component you can pass to a story. The satisfies Meta<typeof Button> is doing real work here: pass an arg the component doesn’t accept and TypeScript complains at compile time rather than leaving you with a silently ignored prop.

Decorators

Some components only make sense inside something else. A dropdown needs room to open, a sidebar item needs a sidebar. Decorators wrap a story in whatever context it needs:

export const InSidebar: Story = {
  decorators: [
    (Story) => (
      <div style={{ width: 250, padding: 16 }}>
        <Story />
      </div>
    ),
  ],
  args: {
    children: 'Sidebar Action',
    variant: 'ghost',
  },
};

They stack in three layers: on a single story, on the meta so every story of that component gets them, or globally in .storybook/preview.ts. Theme providers and i18n providers belong in the global layer. Layout wrappers usually belong on the story, because the whole point is that this particular story renders in a narrow column and the others don’t.

Controls

Controls are the panel underneath the story where you can change props by hand. Storybook infers them from your TypeScript types, so a boolean becomes a toggle and a union of string literals becomes a dropdown, with no configuration at all.

Where the inference isn’t good enough, argTypes takes over:

const meta = {
  component: Card,
  argTypes: {
    backgroundColor: { control: 'color' },
    padding: {
      control: { type: 'range', min: 0, max: 100, step: 4 },
    },
    size: {
      options: ['sm', 'md', 'lg'],
      control: { type: 'radio' },
    },
    icon: {
      control: false, // no sensible widget for a ReactNode
    },
  },
} satisfies Meta<typeof Card>;

Turning a control off with control: false is underrated. Storybook will happily generate a JSON editor for a prop that takes a React node, and nobody has ever typed valid JSX into it.

Play functions

A play function runs after the story renders. It’s the same idea as a Testing Library test, except the setup is the story you already wrote:

import { expect, fn, userEvent, within } from 'storybook/test';

export const SubmitForm: Story = {
  args: {
    onSubmit: fn(),
  },
  play: async ({ canvas, args, step }) => {
    await step('Fill in the form', async () => {
      await userEvent.type(
        canvas.getByLabelText('Email'),
        'user@example.com'
      );
      await userEvent.type(
        canvas.getByLabelText('Password'),
        'secretpassword'
      );
    });

    await step('Submit', async () => {
      await userEvent.click(
        canvas.getByRole('button', { name: 'Sign In' })
      );
    });

    await expect(args.onSubmit).toHaveBeenCalledOnce();
  },
};

canvas gives you Testing Library queries scoped to the story, userEvent does clicks, typing, hovering and keyboard navigation, and fn() creates a spy you can assert against. Note the import: everything comes from storybook/test now, a subpath of the core package. It used to be @storybook/test, and before that @storybook/jest plus @storybook/testing-library. Three homes in three majors.

The step calls are optional and I’d still write them. They group the interactions into collapsible sections in the Interactions panel, which turns a red test into something you can step through, pause and rewind, rather than a stack trace you squint at. For a five-step checkout flow that difference is the whole debugging session.

These same stories run headless in CI through Vitest. The addon writes a storybook project into your Vitest config, so the script is:

{
  "scripts": {
    "test-storybook": "vitest --project=storybook"
  }
}

Then npm run test-storybook in your pipeline. Every story is a test even without a play function, because rendering without throwing is already an assertion worth having. Stories with play functions get their interactions checked on top.

Visual testing

Play functions catch behaviour. They will not notice that a container overflows at 375px or that someone bumped a margin and shunted the whole card grid down by four pixels. That needs pixels compared against pixels, which I’ve written about before in a different context.

Chromatic is made by the Storybook team and plugs straight in:

npx storybook add @chromatic-com/storybook

On every push it screenshots each story across browsers, diffs against the accepted baseline and surfaces the changes on the pull request. You either accept the diff, which makes it the new baseline, or you don’t, which fails the build.

# .github/workflows/chromatic.yml
name: Visual Tests
on: push

jobs:
  chromatic:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - run: npm ci
      - uses: chromaui/action@latest
        with:
          projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}

The fetch-depth: 0 matters. Chromatic works out what changed by walking the git history, and a shallow clone leaves it comparing against nothing useful.

Be warned that visual tests are noisy until you tame them. Anything with a timestamp, a random avatar or an animation mid-flight will diff against itself forever. Freeze that data in the story rather than accepting diffs out of habit, because a team that clicks “accept all” every morning has a screenshot service, not a test suite.

Accessibility

The a11y addon runs axe-core against every story:

npx storybook add @storybook/addon-a11y

An Accessibility panel appears next to Interactions, listing violations, passes and the checks axe couldn’t decide about, each with a link to what to do about it. Storybook’s own docs put the ceiling at “up to 57% of WCAG issues”, which is the figure Deque quote for automated checking generally. It’s a floor for your process, not a certificate. Contrast, missing labels and bad ARIA get caught; whether your focus order makes sense to somebody using a screen reader does not.

Rules can be adjusted per story or per component:

export const WithCustomA11y: Story = {
  parameters: {
    a11y: {
      test: 'error',
      config: {
        rules: [
          { id: 'color-contrast', enabled: true },
          { id: 'landmark-one-main', enabled: false },
        ],
      },
    },
  },
};

The test key decides what happens in CI: 'error' fails the run, 'todo' records the violation without failing, 'off' skips the story. Starting a legacy project on 'todo' and moving components to 'error' as you fix them is far more likely to succeed than switching the whole thing on and drowning in red.

Autodocs

Add tags: ['autodocs'] to the meta and Storybook generates a documentation page: every story rendered in sequence, plus a props table pulled from the TypeScript types.

const meta = {
  component: Button,
  tags: ['autodocs'],
  parameters: {
    docs: {
      description: {
        component: 'Primary UI button with multiple variants and sizes.',
      },
    },
  },
} satisfies Meta<typeof Button>;

For anything more involved, Storybook takes MDX files, so you can write prose around live examples and drop in doc blocks like <Canvas>, <Controls> and <Source> where you want them.

The value here isn’t that the docs are pretty. It’s that they’re generated from the same source as the components, so renaming a prop updates the table by itself. Every hand-written component page I’ve maintained in a wiki was wrong within two months.

Addons worth keeping

The addon list is long and most of it you will never open. These are the ones I leave installed:

  • Viewport for checking layouts at phone and tablet widths without resizing the window.
  • Themes for flipping between light and dark, which is where about half of my contrast bugs turn up.
  • Backgrounds for putting a component on a dark surface, because “looks fine on white” is not the same as fine.
  • Measure & Outline for when the spacing is off by an amount you can see but not name.

Everything else I’ve installed at some point and removed again. Addons go in .storybook/main.ts and most need no configuration beyond being listed.

One story, several kinds of test

Because stories are plain modules, other tools can import them. That gives you layers of checking over a single definition:

Test type Tool What it catches
Render Stories themselves Crashes, missing providers
Interaction Play functions via Vitest Broken flows, handlers that never fire
Visual Chromatic Layout shifts, contrast, overflow
Accessibility a11y addon (axe-core) Missing labels, bad roles, contrast

End-to-end tools can point at the story URL directly, which is handy for testing a single component under a real browser without booting the whole app:

import { test, expect } from '@playwright/test';

test('form submission works', async ({ page }) => {
  await page.goto('/storybook/iframe.html?id=forms-login--submit-form');
  await expect(page.getByText('Welcome back')).toBeVisible();
});

What it costs

Now the annoying part I promised. Storybook is a second build of your application, and second builds need feeding. The config drifts from the app config, a Next.js upgrade breaks the framework package before the framework package catches up, and the import paths move on every major version. The testing utilities alone have lived in three different packages across three releases, and each move meant a morning of codemods and confused imports.

There’s also the rot problem. Stories only stay honest if somebody looks at them. A component library with 200 stories and no visual testing has 200 files that used to be true, and nobody notices until a designer opens the published build and asks why half of it looks nothing like production. Wiring up Chromatic or the Vitest run on day one is what keeps that from happening, not good intentions.

So it isn’t free, and on a small project with two components it isn’t worth it. Where it pays is a shared component library, or any codebase where more than one person edits the UI and nobody can hold all the states in their head.

Next thing on my list is getting the Vitest run fast enough to live in a pre-commit hook instead of only in CI. Not there yet

‘Till next time!