React Storybook: Component Documentation and Testing (2026)

Storybook is the industry-standard tool for building UI components in isolation. It gives you a live component catalog, interactive controls, accessibility auditing, and a visual regression testing pipeline — all without spinning up your full application. Storybook 8 (2024+) ships with Vite by default and makes the CSF3 format the standard.

Setup

# Add Storybook to an existing Vite/Next.js project
npx storybook@latest init

# Start Storybook dev server
npm run storybook

# Build static Storybook for deployment
npm run build-storybook

Storybook 8 auto-detects your framework (Vite, Next.js, webpack) and installs the right builder. Your .storybook/ folder gets a main.ts and preview.ts.

Writing Stories: CSF3

Component Story Format 3 (CSF3) is the current standard — stories are plain objects, not functions:

// src/components/Button/Button.stories.ts
import type { Meta, StoryObj } from '@storybook/react'
import { Button } from './Button'

// Meta: configures the component in Storybook's sidebar
const meta: Meta<typeof Button> = {
  title: 'UI/Button',         // Sidebar path
  component: Button,
  tags: ['autodocs'],         // Auto-generate docs page
  argTypes: {
    variant: {
      control: 'select',
      options: ['primary', 'secondary', 'danger'],
    },
    size: {
      control: 'radio',
      options: ['sm', 'md', 'lg'],
    },
    onClick: { action: 'clicked' },   // Log clicks in Actions panel
  },
}

export default meta
type Story = StoryObj<typeof Button>

// Each named export = one story
export const Primary: Story = {
  args: {
    children: 'Click me',
    variant: 'primary',
    size: 'md',
  },
}

export const Secondary: Story = {
  args: {
    children: 'Secondary',
    variant: 'secondary',
    size: 'md',
  },
}

export const Large: Story = {
  args: {
    children: 'Large Button',
    variant: 'primary',
    size: 'lg',
  },
}

export const Disabled: Story = {
  args: {
    children: 'Disabled',
    variant: 'primary',
    disabled: true,
  },
}

Args and Controls

Args are the props Storybook passes to your component. The Controls panel auto-generates UI to modify them:

// argTypes control the Controls panel UI
const meta: Meta<typeof Input> = {
  component: Input,
  argTypes: {
    type: {
      control: 'select',
      options: ['text', 'email', 'password', 'number'],
      description: 'HTML input type',
    },
    label: { control: 'text' },
    placeholder: { control: 'text' },
    disabled: { control: 'boolean' },
    errorMessage: { control: 'text', description: 'Shows error state when set' },
    // Hide internal/callback props from controls
    onChange: { table: { disable: true } },
    ref: { table: { disable: true } },
  },
}

// Story with all states — renders multiple variants at once
export const AllStates: Story = {
  render: () => (
    <div className="flex flex-col gap-4">
      <Input label="Default" placeholder="Enter text..." />
      <Input label="With value" value="hello@example.com" type="email" />
      <Input label="Disabled" disabled placeholder="Can't edit" />
      <Input label="Error" errorMessage="This field is required" />
    </div>
  ),
}

Decorators and Providers

Decorators wrap every story — use them to provide context, themes, or routing:

// .storybook/preview.ts — global decorators apply to every story
import type { Preview } from '@storybook/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import '../src/index.css'    // Import your global styles

const queryClient = new QueryClient({
  defaultOptions: { queries: { retry: false } }
})

const preview: Preview = {
  decorators: [
    (Story) => (
      <QueryClientProvider client={queryClient}>
        <MemoryRouter>
          <div className="p-6">
            <Story />
          </div>
        </MemoryRouter>
      </QueryClientProvider>
    ),
  ],
  parameters: {
    layout: 'centered',   // 'centered' | 'fullscreen' | 'padded'
    backgrounds: {
      default: 'dark',
      values: [
        { name: 'dark', value: '#030712' },
        { name: 'light', value: '#ffffff' },
      ],
    },
  },
}

export default preview

// Story-level decorator — overrides global for one story
export const InDarkContainer: Story = {
  decorators: [
    (Story) => (
      <div className="bg-gray-900 p-8 rounded-xl">
        <Story />
      </div>
    ),
  ],
  args: { variant: 'primary' },
}

Interaction Testing

The play function lets you write user interaction tests that run inside Storybook's Interactions panel:

// Install
npm install @storybook/test @storybook/addon-interactions

// LoginForm.stories.ts
import { userEvent, within, expect } from '@storybook/test'

export const SuccessfulLogin: Story = {
  args: { onLogin: fn() },   // fn() from @storybook/test — spy function

  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement)

    // Type into fields
    await userEvent.type(canvas.getByLabelText('Email'), 'user@example.com')
    await userEvent.type(canvas.getByLabelText('Password'), 'secret123')

    // Click submit
    await userEvent.click(canvas.getByRole('button', { name: /sign in/i }))

    // Assert
    await expect(args.onLogin).toHaveBeenCalledWith({
      email: 'user@example.com',
      password: 'secret123',
    })
  },
}

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

    // Submit without filling fields
    await userEvent.click(canvas.getByRole('button', { name: /sign in/i }))

    // Error messages should appear
    await expect(canvas.getByText('Email is required')).toBeInTheDocument()
    await expect(canvas.getByText('Password is required')).toBeInTheDocument()
  },
}

Accessibility Testing

# Install a11y addon
npm install @storybook/addon-a11y

# .storybook/main.ts
addons: ['@storybook/addon-a11y']
// Per-story a11y config
export const IconButton: Story = {
  args: { icon: '🔍', label: 'Search' },
  parameters: {
    a11y: {
      config: {
        rules: [
          { id: 'color-contrast', enabled: true },
          { id: 'button-name', enabled: true },
        ],
      },
    },
  },
}

// Disable a11y check for a story (e.g., WIP)
export const Draft: Story = {
  parameters: { a11y: { disable: true } },
}

Visual Regression with Chromatic

npm install --save-dev chromatic

# Run visual tests (uploads to Chromatic cloud)
npx chromatic --project-token=<your-token>
# .github/workflows/chromatic.yml
name: Chromatic
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 }}
          buildScriptName: build-storybook
How Chromatic works: It renders every story in a headless browser, captures a pixel-perfect screenshot, and compares it to the approved baseline. Any visual diff blocks the PR until a designer reviews and accepts the change — catching accidental CSS regressions automatically.

Auto-Generated Docs

// Add 'autodocs' tag to meta to generate a Docs page
const meta: Meta<typeof Button> = {
  component: Button,
  tags: ['autodocs'],
  parameters: {
    docs: {
      description: {
        component: 'Primary action button. Use `variant="danger"` for destructive actions.',
      },
    },
  },
}

// Enrich with JSDoc — auto-shown in Props table
interface ButtonProps {
  /** Visual style of the button */
  variant: 'primary' | 'secondary' | 'danger'
  /** Controls padding and font size */
  size?: 'sm' | 'md' | 'lg'
  /** Shows spinner and disables interaction */
  loading?: boolean
}