Building Components in Isolation with Storybook: Stories, Args and Controls, Decorators, Docs and Tests

Key takeaways

Storybook renders components from small CSF files called stories, each one a named state such as loading, error or long text. The same stories then drive Controls, docs pages, interaction tests and visual regression. This guide covers args, decorators for app-level providers, play functions with storybook/test, and what changed in Storybook 8 and 9.

Introduction

Storybook is an open-source tool for developing UI components in isolation. It allows you to build and test components outside your app, making development faster and more reliable.

Why Storybook?

Traditional development:

# Need to:
1. Start entire app
2. Navigate to component
3. Set up specific state
4. Test edge cases manually
5. Repeat for each component

With Storybook:

# Just:
1. Run Storybook
2. See all components
3. Test all states instantly
4. Document automatically

What Storybook actually is

Storybook is a separate development server that runs next to your app. It uses the same bundler family (Vite or webpack, through a “framework” package such as @storybook/react-vite or @storybook/nextjs) to compile your components, but instead of your app’s routes it renders stories: small, named examples of a component in one specific state. Stories are plain ES modules in the Component Story Format (CSF), so they are ordinary code that your editor, type checker and test tools understand.

The value comes from that last point. A story such as “UserCard with a very long name” is written once and then reused in several ways:

  • Isolated development: you build a component against its stories instead of clicking through the app to reach the state you need, which is where much of the time goes for edge cases like empty, loading, and error states.
  • Stories as test fixtures: the same stories feed interaction tests, visual regression, and a11y checks, so each state is written once.
  • Living documentation: props tables and examples are generated from the component, so docs drift less than a hand-written wiki.

The cost is a second build to maintain. Storybook has its own configuration in .storybook/, and anything your app sets up globally (providers, CSS, aliases, environment variables) has to be made available there too. Components that fetch their own data or read from a global store are awkward to put in stories; that friction is often a useful signal that the component should receive its data as props.

Version note: this guide targets Storybook 8 and 9. Storybook 9 folded the former “essentials” addons (controls, actions, viewport, backgrounds) and the interactions panel into the core package and moved the testing utilities to storybook/test. Where the two versions differ, both are shown.

Installation

New Project

npx storybook@latest init

The installer inspects package.json, picks a framework package that matches your stack, writes .storybook/main.ts and .storybook/preview.ts, adds storybook and build-storybook scripts, and creates a few example stories you can delete later.

Existing React Project

npx storybook@latest init

# Start Storybook
npm run storybook

The dev server runs on port 6006 by default. The detection is not infallible: in a monorepo, or when an app uses a custom webpack or Vite configuration, check that .storybook/main.ts points stories at the right glob and that path aliases such as @/components resolve. An import error in one story file shows up as a red error screen for that story while the others keep working, which makes misconfiguration easy to spot.

Writing Stories

Basic Story

// Button.tsx
export interface ButtonProps {
  label: string;
  onClick?: () => void;
  variant?: 'primary' | 'secondary';
}

export function Button({ label, onClick, variant = 'primary' }: ButtonProps) {
  return (
    <button className={`btn btn-${variant}`} onClick={onClick}>
      {label}
    </button>
  );
}
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';

const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  tags: ['autodocs'],
};

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

export const Primary: Story = {
  args: {
    label: 'Button',
    variant: 'primary',
  },
};

export const Secondary: Story = {
  args: {
    label: 'Button',
    variant: 'secondary',
  },
};

The default export (meta) describes the component: its place in the sidebar (title), the component itself, and settings shared by every story in the file. Each named export is one story. With args, a story is data rather than JSX: Storybook renders <Button {...args} /> for you, which is what lets the Controls panel edit props live and lets other stories reuse the same args. StoryObj<typeof Button> type-checks those args against ButtonProps, so a typo such as varient is a compile error.

In Storybook 8+ import the types from your framework package (@storybook/react-vite, @storybook/nextjs); @storybook/react still works for types in most setups. If you omit title, Storybook derives it from the file path, which keeps the sidebar in sync with the folder structure and is usually the better choice.

Interactive Stories

import { fn } from 'storybook/test'; // Storybook 8: '@storybook/test'

export const WithClick: Story = {
  args: {
    label: 'Click me',
    onClick: fn(),   // logged in the Actions panel, and assertable in tests
  },
};

An alert() in a story works but blocks the page and cannot be checked by a test. fn() creates a spy: clicks show up in the Actions panel with their arguments, and a play function can assert that the handler was called (see Interactions below).

Component States

// UserCard.stories.tsx
import { UserCard } from './UserCard';

export default {
  title: 'Components/UserCard',
  component: UserCard,
};

export const Default = {
  args: {
    name: 'Alice',
    role: 'Developer',
    avatar: 'https://i.pravatar.cc/150?img=1',
  },
};

export const NoAvatar = {
  args: {
    name: 'Bob',
    role: 'Designer',
  },
};

export const LongName = {
  args: {
    name: 'Christopher Alexander Johnson',
    role: 'Senior Software Engineer',
  },
};

export const Loading = {
  args: {
    isLoading: true,
  },
};

export const WithError = {
  args: {
    error: 'Failed to load user',
  },
};

This file is the real point of Storybook. The states that break in production are rarely the default one; they are the missing avatar, the name that overflows its container, the loading skeleton nobody looked at since it was written, and the error message. Reproducing those in the running app means faking slow networks or failing APIs. As stories, each is one click away, and a reviewer can check all of them in a pull request’s deployed Storybook without running anything locally.

The error story is named WithError rather than Error on purpose: exporting a story called Error shadows the global Error constructor inside the module, so any new Error(...) in that file suddenly refers to the story object. You can still show “Error” in the sidebar with name: 'Error'.

Args and Controls

import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';

const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  argTypes: {
    variant: {
      control: 'select',
      options: ['primary', 'secondary', 'danger'],
    },
    size: {
      control: 'radio',
      options: ['small', 'medium', 'large'],
    },
    disabled: {
      control: 'boolean',
    },
    label: {
      control: 'text',
    },
  },
};

export default meta;

This argTypes block assumes a Button that also has size and disabled props; with the ButtonProps shown earlier, TypeScript would reject the extra keys. For most props you do not need argTypes at all: Storybook infers controls from the component’s TypeScript types (a union of string literals becomes a select, a boolean becomes a toggle). Explicit argTypes are for overriding that inference, for example showing a radio group instead of a dropdown, or hiding a prop with table: { disable: true }.

Controls only affect stories that render through args. A story with a custom render function that hard-codes props will show the controls but ignore them, which is a common source of “the controls do nothing” confusion.

Decorators

Global Decorator

// .storybook/preview.tsx
import { ThemeProvider } from '../src/theme';
import '../src/index.css';   // global styles the app normally loads

export const decorators = [
  (Story) => (
    <ThemeProvider>
      <Story />
    </ThemeProvider>
  ),
];

A decorator is a wrapper component around every story. The global one in preview.tsx is where you recreate what your app’s root provides: theme, router, i18n, a React Query or Redux provider. When a component “works in the app but crashes in Storybook” with an error such as useNavigate() may be used only in the context of a <Router> component or a missing-context error from your state library, a missing decorator is almost always the cause. Keep store providers fresh per story (create the store inside the decorator), otherwise state leaks from one story into the next as you click through the sidebar.

Story-specific Decorator

export const Primary: Story = {
  args: { label: 'Button' },
  decorators: [
    (Story) => (
      <div style={{ padding: '3rem' }}>
        <Story />
      </div>
    ),
  ],
};

Addons

Essential Addons

# Storybook 8 only; in Storybook 9 these features are built into core
npm install --save-dev @storybook/addon-essentials

Includes:

  • Docs - Auto-generated documentation
  • Controls - Interactive args editing
  • Actions - Event logging
  • Viewport - Responsive preview
  • Backgrounds - Test on different backgrounds
  • Toolbars - Custom toolbar items

a11y Addon

npm install --save-dev @storybook/addon-a11y
// .storybook/main.ts
export default {
  addons: ['@storybook/addon-a11y'],
};

The a11y addon runs axe-core against the rendered story and lists violations such as missing labels, insufficient color contrast or invalid ARIA attributes. It catches a useful share of mechanical problems, but not whether the component is actually usable with a keyboard or a screen reader; treat a clean report as a floor, not proof of accessibility. Contrast results also depend on the background, so check stories with the backgrounds your app really uses.

Interactions

# Storybook 8: npm install --save-dev @storybook/addon-interactions @storybook/test
# Storybook 9: built in, nothing to install
import { expect, fn, userEvent, within } from 'storybook/test'; // SB 8: '@storybook/test'

export const WithInteraction: Story = {
  args: { label: 'Click me', onClick: fn() },
  play: async ({ args, canvasElement }) => {
    const canvas = within(canvasElement);
    const button = canvas.getByRole('button', { name: 'Click me' });
    
    await userEvent.click(button);
    await expect(args.onClick).toHaveBeenCalledTimes(1);
  },
};

The older @storybook/testing-library and @storybook/jest packages are deprecated; their APIs now live in @storybook/test (Storybook 8) and storybook/test (Storybook 9). The play function runs after the story renders, in the real browser, and the Interactions panel shows each step so you can replay and debug it. Asserting on the spy is more robust than asserting on text: this Button never changes its label, so a check for a “Clicked!” text would simply fail.

Documentation

Auto-generated Docs

import type { Meta } from '@storybook/react';

const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  tags: ['autodocs'], // Enable auto docs
  parameters: {
    docs: {
      description: {
        component: 'A button component with multiple variants.',
      },
    },
  },
};

MDX Documentation

{/* Button.mdx */}
import { Meta, Canvas, Controls } from '@storybook/blocks'; // SB 9: '@storybook/addon-docs/blocks'
import * as ButtonStories from './Button.stories';

<Meta of={ButtonStories} />

# Button

A flexible button component.

## Usage

<Canvas of={ButtonStories.Primary} />

## Props

<Controls of={ButtonStories.Primary} />

tags: ['autodocs'] generates a docs page from the component’s types and JSDoc comments, which is enough for most components. Write an MDX page when you need prose: usage guidelines, do’s and don’ts, how the component fits a layout. Since Storybook 8, stories can no longer be defined inside MDX (the old .stories.mdx format with inline <Story> blocks); MDX files reference stories from CSF files with of={...}. That split keeps every story testable as normal code, and the docs page updates when the story does.

Testing

Interaction Testing

export const LoginForm: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    // Fill form
    await userEvent.type(canvas.getByLabelText('Email'), '[email protected]');
    await userEvent.type(canvas.getByLabelText('Password'), 'password123');
    
    // Submit
    await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
    
    // Assert
    await expect(await canvas.findByText('Success!')).toBeInTheDocument();
  },
};

findByText waits for the element to appear, while getByText checks only once. If the form submits asynchronously (a fetch, even a mocked one), getByText('Success!') fails immediately with Unable to find an element with the text: Success!, even though the message appears a moment later. Use findBy* for anything that appears after an async step. A form story like this also needs its network calls mocked, typically with Mock Service Worker through msw-storybook-addon, so the story does not depend on a real backend.

Running Stories as Tests

# Storybook 8: test-runner (Playwright under the hood)
npm install --save-dev @storybook/test-runner
npm run test-storybook

# Visual testing with Chromatic
npx chromatic --project-token=<token>

The test runner opens every story in a headless browser and fails if it throws while rendering or if its play function fails. That makes every story a smoke test for free, even those without a play function. In Storybook 9 the recommended route is the Vitest addon (npx storybook add @storybook/addon-vitest), which runs stories as Vitest tests in browser mode and shows results in the Storybook UI. See Vitest for the underlying runner.

Chromatic is a hosted service from the Storybook maintainers that screenshots every story and shows pixel differences against the last approved baseline. Visual tests catch what assertions do not, such as a CSS change that shifts a layout, but they also flag legitimate changes, so someone has to review and accept diffs. Keep stories deterministic (fixed dates, no random data, fonts loaded) or the diffs turn into noise.

Deployment

Build Static Storybook

npm run build-storybook

Deploy to GitHub Pages

# .github/workflows/storybook.yml
name: Deploy Storybook

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run build-storybook
      - uses: peaceiris/actions-gh-pages@v4
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./storybook-static

build-storybook produces a static site in storybook-static/ that any static host can serve. GitHub Pages serves project sites under a sub-path (/repo-name/); Storybook uses relative asset paths, so this usually works, but a custom managerHead or absolute asset URLs in your preview can break under a sub-path. Many teams deploy a Storybook preview per pull request instead (Chromatic, Netlify or Vercel previews), which is where reviewers get the most out of it.

Render through args, and run the stories in CI

// Good: reusable
export const Primary: Story = {
  args: { variant: 'primary' }
};

// Bad: hardcoded
export const Primary: Story = {
  render: () => <Button variant="primary" />
};

A hard-coded render ignores Controls, cannot be composed (args: { ...Primary.args, disabled: true }), and hides the props from the docs page. Use render only when the story genuinely needs extra markup around the component, and even then pass args through: render: (args) => <Toolbar><Button {...args} /></Toolbar>.

A common way Storybooks decay is not too few stories but stale ones: a Storybook set up once, filled with happy-path examples, and then left behind as components evolved. Stories that nobody runs break silently. Running them in CI (test runner or Vitest addon) is what keeps a Storybook honest, because a story that fails to render then fails the build.


Frequently Asked Questions (FAQ)

Q. Why does my component render in the app but break inside Storybook?

A. Storybook renders each story in isolation, so anything your app provides at the root (theme providers, routers, state stores, global CSS) is missing unless you add it. Wrap stories with a decorator in .storybook/preview to supply those providers, and import global styles there too. Adding them once in the preview file is usually better than repeating the same wrapper in every story file.