Skip to content

Storybook for Design Systems

advanced20 min read

Why Storybook Is Non-Negotiable for Design Systems

Building components in the context of a full application is like assembling a car while driving it. You cannot see the component in isolation, test its edge cases, or verify its accessibility without the noise of the surrounding page. Storybook gives you a workshop — an isolated environment where every component gets its own page, every variant has its own story, and every interaction can be tested independently.

For design systems specifically, Storybook serves three critical roles:

  1. Development environment — build components in isolation, test every state
  2. Documentation — auto-generated docs from your props and stories
  3. Testing platform — visual regression, interaction testing, accessibility auditing
Mental Model

Storybook is like a showroom for your component library. Each component gets a display case (a story) that shows it in various configurations. Visitors (developers, designers, QA) can interact with each display, tweak the settings, and verify everything works — without needing to spin up the full application or navigate to the right page. If your component library is a product, Storybook is its demo.

Component Story Format 3 (CSF3)

CSF3 is the current standard for writing stories. Each story is an object with args that map to your component's props:

// 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'],
  argTypes: {
    variant: {
      control: 'select',
      options: ['default', 'destructive', 'outline', 'ghost'],
      description: 'Visual style of the button',
    },
    size: {
      control: 'radio',
      options: ['sm', 'md', 'lg'],
      description: 'Size of the button',
    },
    disabled: {
      control: 'boolean',
    },
  },
};

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

export const Default: Story = {
  args: {
    children: 'Button',
    variant: 'default',
    size: 'md',
  },
};

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

export const AllSizes: Story = {
  render: () => (
    <div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
      <Button size="sm">Small</Button>
      <Button size="md">Medium</Button>
      <Button size="lg">Large</Button>
    </div>
  ),
};

Key CSF3 Concepts

Meta defines the component-level configuration — which component, what controls, how to categorize it. The tags: ['autodocs'] flag tells Storybook to auto-generate a documentation page.

Story objects define individual states. Each story has args (the props to render with) and optionally a render function for custom layouts.

argTypes configure the controls panel — dropdowns, radios, booleans, text inputs — that let people tweak props interactively.

Quiz
In CSF3, what is the difference between args and argTypes?

Play Functions for Interaction Testing

Play functions let you script user interactions and assert on the result — directly in your stories. This replaces a significant amount of integration testing:

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

export const DialogInteraction: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    const trigger = canvas.getByRole('button', { name: 'Open Dialog' });
    await userEvent.click(trigger);

    const dialog = canvas.getByRole('dialog');
    await expect(dialog).toBeVisible();

    const title = within(dialog).getByText('Confirm Action');
    await expect(title).toBeVisible();

    await userEvent.keyboard('{Escape}');
    await expect(dialog).not.toBeVisible();

    await expect(trigger).toHaveFocus();
  },
};

This story:

  1. Clicks the trigger button
  2. Verifies the dialog opens
  3. Checks the title is visible
  4. Presses Escape
  5. Verifies the dialog closes
  6. Verifies focus returns to the trigger

That last assertion — focus restoration — is an accessibility requirement that is extremely hard to test with unit tests. Play functions make it trivial.

Testing Form Components

export const FormValidation: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    const emailInput = canvas.getByLabelText('Email');
    const submitButton = canvas.getByRole('button', { name: 'Submit' });

    await userEvent.click(submitButton);
    await expect(canvas.getByText('Email is required')).toBeVisible();

    await userEvent.type(emailInput, 'not-an-email');
    await userEvent.click(submitButton);
    await expect(canvas.getByText('Invalid email address')).toBeVisible();

    await userEvent.clear(emailInput);
    await userEvent.type(emailInput, 'user@example.com');
    await userEvent.click(submitButton);
    await expect(canvas.queryByText('Invalid email address')).not.toBeInTheDocument();
  },
};
Quiz
Why are Storybook play functions better than separate integration tests for design system components?

Visual Regression Testing with Chromatic

Code changes can cause unintended visual changes — a padding tweak in a shared utility shifts every component that uses it. Visual regression testing catches these by comparing screenshots.

Chromatic (built by the Storybook team) automates this:

npx chromatic --project-token=your-token

For every story in your Storybook:

  1. Chromatic renders a screenshot
  2. Compares it to the baseline from the previous build
  3. Highlights pixel-level differences
  4. Creates a review workflow for intentional changes
# .github/workflows/chromatic.yml
name: Visual Tests
on: push

jobs:
  chromatic:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npx chromatic --auto-accept-changes=main
        env:
          CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
Snapshot Count

Chromatic charges per snapshot. A component with 10 stories generates 10 snapshots per build. A design system with 50 components averaging 8 stories each generates 400 snapshots per build. Factor this into cost planning.

Self-Hosted Alternatives

If Chromatic's pricing does not work for you, alternatives exist:

  • Playwright visual comparison — free, self-hosted, integrates with your existing Playwright setup
  • Percy (BrowserStack) — similar to Chromatic, different pricing model
  • Loki — open source, runs against your Storybook directly
// Playwright visual regression against Storybook
import { test, expect } from '@playwright/test';

test('Button default', async ({ page }) => {
  await page.goto('http://localhost:6006/iframe.html?id=components-button--default');
  await expect(page).toHaveScreenshot('button-default.png');
});

The Accessibility Addon

The a11y addon runs axe-core on every story and surfaces violations directly in the Storybook UI:

npm install @storybook/addon-a11y
// .storybook/main.ts
const config: StorybookConfig = {
  addons: [
    '@storybook/addon-a11y',
  ],
};

Once installed, every story gets an "Accessibility" panel showing:

  • Violations — things that definitely fail WCAG
  • Passes — things that definitely pass
  • Incomplete — things that need manual review (like color contrast on complex backgrounds)

You can also enforce accessibility checks in play functions:

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

export const AccessibleDialog: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    const dialog = canvas.getByRole('dialog');
    await expect(dialog).toHaveAttribute('aria-modal', 'true');
    await expect(dialog).toHaveAttribute('aria-labelledby');

    const labelId = dialog.getAttribute('aria-labelledby');
    const label = document.getElementById(labelId!);
    await expect(label).toBeVisible();
    await expect(label?.textContent).toBeTruthy();
  },
};
Quiz
The a11y addon shows 0 violations for your Dialog component story. Does this mean your dialog is fully accessible?

Auto-Generated Documentation

The autodocs tag generates a documentation page for every component with:

  • A live preview of the default story
  • A props table extracted from TypeScript types
  • Controls for interactive exploration
  • All stories rendered in a gallery
const meta: Meta<typeof Button> = {
  title: 'Components/Button',
  component: Button,
  tags: ['autodocs'],
  parameters: {
    docs: {
      description: {
        component: 'Primary action button. Supports multiple variants and sizes.',
      },
    },
  },
};

Enhancing Auto-Docs with JSDoc

Storybook extracts JSDoc comments from your component props and displays them in the docs:

interface ButtonProps {
  /** Visual style variant */
  variant?: 'default' | 'destructive' | 'outline' | 'ghost';
  /** Controls the size and padding */
  size?: 'sm' | 'md' | 'lg';
  /** Disables the button and prevents interaction */
  disabled?: boolean;
}

These comments appear in the props table alongside each prop's type and default value.

Storybook Test Runner

The test runner executes all your stories and play functions in a headless browser as part of CI:

npm install @storybook/test-runner
npx test-storybook --url http://localhost:6006

Add it to your CI pipeline:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx storybook build
      - run: npx http-server storybook-static --port 6006 &
      - run: npx wait-on http://localhost:6006
      - run: npx test-storybook --url http://localhost:6006

This catches:

  • Stories that fail to render (runtime errors)
  • Play function assertions that fail
  • Accessibility violations (when configured)
Key Rules
  1. 1Every component gets at least: default story, each variant, each size, disabled state, loading state, error state
  2. 2Use play functions for interaction testing — co-location with stories enables visual debugging
  3. 3Add tags: ['autodocs'] to every component meta for automatic documentation
  4. 4Run the test runner in CI to catch rendering errors and interaction regressions
  5. 5Visual regression testing (Chromatic/Playwright) catches unintended style changes across the system

Storybook Composition

Large organizations often have multiple Storybooks — one for the design system, one for each product. Composition lets you embed one Storybook inside another:

// .storybook/main.ts
const config: StorybookConfig = {
  refs: {
    'design-system': {
      title: 'Design System',
      url: 'https://design-system-storybook.example.com',
    },
  },
};

Now your product Storybook shows the design system's components in the sidebar alongside your product's components. Designers and developers can reference the design system without switching contexts.

What developers doWhat they should do
Writing stories only for the happy path (default props, no edge cases)
Edge cases are where components break. If you do not have a story for a 200-character button label, nobody will catch the overflow bug until it ships
Write stories for every meaningful state: empty, loading, error, overflow text, single item, many items, disabled, focused
Putting business logic in stories
Stories that depend on external services are flaky and slow. Pass all data through args so stories are deterministic and fast
Stories should test the component in isolation with mock data — no API calls, no state management, no routing
Skipping interaction tests because you have unit tests
A unit test can verify a state machine transition, but only a play function verifies that clicking a button actually triggers that transition AND the UI updates correctly
Play functions test user-facing behavior (click, type, keyboard navigation). Unit tests test logic. Both are needed
Storybook 8 Performance: On-Demand Loading

Storybook 8 introduced significant performance improvements. The most impactful: on-demand story loading. In Storybook 7, every story was loaded upfront, which made large Storybooks (500+ stories) slow to start. Storybook 8 loads stories on demand — only the currently viewed story and its dependencies are loaded. This makes startup time nearly constant regardless of project size.

Other Storybook 8 improvements:

  • SWC compiler instead of Babel for faster story compilation
  • Lazy compilation in development mode
  • Reduced memory usage through smarter story indexing
  • Framework-agnostic testing with the portable stories API