Component Variants in Storybook

This page is part of the Storybook Isolation Workflows guide. Component variants sit at the intersection of story authoring and visual quality gates: you define every meaningful visual state once, and the testing pipeline guards every one of those states on every merge.

The workflow described here feeds directly into visual regression snapshot strategies, where baselines are captured and diffs reviewed. Getting the variant matrix right upstream — clean types, constrained args, and deterministic decorators — is what makes downstream diff review tractable.


The variant testing pipeline

The diagram below shows how story authoring, snapshot capture, and CI gating connect. Each node corresponds to a section of this page.

Component variant testing pipeline A left-to-right flow diagram showing: TypeScript type contract feeds CSF3 stories, which build into Storybook static output, captured by Chromatic for visual diffs, then CI gating blocks or passes the pull request. TypeScript type contract CSF3 stories args + argTypes Storybook build static output Chromatic snapshot + diff CI gate block / pass PR Step 1 Steps 2–3 Step 4 Step 5 Step 6

Prerequisites


Step-by-step implementation

Step 1 — Define a TypeScript variant type contract

Intent: Give the variant matrix a single source of truth. Every story and every test derives its permutations from these types, so a prop rename propagates automatically via TypeScript errors rather than silent snapshot drift.

// src/components/Button/Button.types.ts
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'destructive';
export type ButtonSize    = 'sm' | 'md' | 'lg';
export type ButtonState  = 'default' | 'loading' | 'error' | 'disabled';

export interface ButtonProps {
  variant:  ButtonVariant;
  size:     ButtonSize;
  state:    ButtonState;
  children: React.ReactNode;
  onClick?: React.MouseEventHandler<HTMLButtonElement>;
}

Verify: Run tsc --noEmit. Zero errors means the type contract compiles cleanly before any stories are written.


Step 2 — Configure main.ts for deterministic builds

Intent: Disable telemetry and pin the React docgen strategy so Storybook’s output is reproducible between local and CI runs — a prerequisite for stable snapshot hashes.

// .storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';

const config: StorybookConfig = {
  stories: ['../src/**/*.stories.@(ts|tsx)'],
  framework: {
    name: '@storybook/react-vite',
    options: {},
  },
  staticDirs: ['../public'],
  core: {
    disableTelemetry: true,       // prevents non-deterministic network calls during build
    disableWhatsNewNotifications: true,
  },
  typescript: {
    reactDocgen: 'react-docgen-typescript', // stable prop table generation
    reactDocgenTypescriptOptions: {
      shouldExtractLiteralValuesFromEnum: true,
      propFilter: (prop) => !prop.name.startsWith('aria-'), // trim noise from union types
    },
  },
};
export default config;

Verify: Run npx storybook build --quiet locally. The storybook-static/ directory should be reproducible across two successive builds (compare checksums with find storybook-static -type f | sort | xargs md5sum).


Step 3 — Author CSF3 stories with a constrained argTypes matrix

Intent: Declare every meaningful variant as a named story export. Use argTypes to constrain which values appear in the Storybook controls panel and to document the matrix for reviewers.

Mapping complex prop shapes — such as compound variants or conditional prop relationships — is covered in detail on the argtable mapping page.

// src/components/Button/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
import type { ButtonProps } from './Button.types';

const meta: Meta<ButtonProps> = {
  component: Button,
  title: 'Design System/Button',
  argTypes: {
    variant: {
      control: { type: 'select' },
      options: ['primary', 'secondary', 'ghost', 'destructive'] satisfies ButtonProps['variant'][],
      description: 'Visual treatment and semantic intent of the button',
      table: { defaultValue: { summary: 'primary' } },
    },
    size: {
      control: { type: 'radio' },
      options: ['sm', 'md', 'lg'] satisfies ButtonProps['size'][],
    },
    state: {
      control: { type: 'select' },
      options: ['default', 'loading', 'error', 'disabled'] satisfies ButtonProps['state'][],
    },
  },
  // Wrap every story in the surface token so snapshot backgrounds are stable
  decorators: [
    (Story) => (
      <div style={{ padding: '1rem', background: 'var(--color-surface-1, #fff)' }}>
        <Story />
      </div>
    ),
  ],
  parameters: {
    // Disable the Storybook backgrounds addon — backgrounds are managed by the decorator above
    backgrounds: { disable: true },
  },
};
export default meta;
type Story = StoryObj<ButtonProps>;

// ── Named variant stories ───────────────────────────────────────────────────

const baseArgs: ButtonProps = {
  variant: 'primary',
  size: 'md',
  state: 'default',
  children: 'Save changes',
};

export const Primary: Story    = { args: { ...baseArgs } };
export const Secondary: Story  = { args: { ...baseArgs, variant: 'secondary' } };
export const Ghost: Story      = { args: { ...baseArgs, variant: 'ghost' } };
export const Destructive: Story = { args: { ...baseArgs, variant: 'destructive', children: 'Delete permanently' } };

// ── State stories (use primary as the canonical variant) ────────────────────

export const Loading: Story  = { args: { ...baseArgs, state: 'loading', children: 'Saving…' } };
export const ErrorState: Story = { args: { ...baseArgs, state: 'error', children: 'Retry' } };
export const Disabled: Story  = { args: { ...baseArgs, state: 'disabled' } };

// ── Size scale (visual regression for layout impact) ───────────────────────

export const Small: Story  = { args: { ...baseArgs, size: 'sm' } };
export const Large: Story  = { args: { ...baseArgs, size: 'lg' } };

Verify: Open Storybook in the browser. Every named export should appear in the sidebar under Design System/Button, and the controls panel should only show the three props defined in argTypes.


Step 4 — Add interaction testing for stateful variants

Intent: Before Chromatic captures a snapshot, drive the component into interactive states (hover, focus, loading) using the play function. This ensures the baseline captures the actual rendered state rather than the initial paint.

// Append to Button.stories.tsx
import { userEvent, within, waitFor } from '@storybook/test';

export const FocusVisible: Story = {
  args: { ...baseArgs, children: 'Focus me' },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    const button = canvas.getByRole('button', { name: /focus me/i });
    // Tab into the button so :focus-visible styles apply before snapshot capture
    await userEvent.tab();
    await waitFor(() => expect(button).toHaveFocus());
  },
};

Verify: In the Storybook UI, the FocusVisible story’s Interactions panel should show a green checkmark after the waitFor assertion resolves.


Step 5 — Capture baselines with Chromatic

Intent: Publish the Storybook to Chromatic for the first time to record initial baselines. Every subsequent build diffs against these baselines; unreviewed changes block the CI gate.

# First run — accepts all changes as baselines
npx chromatic \
  --project-token=$CHROMATIC_TOKEN \
  --auto-accept-changes \
  --build-script-name=build-storybook

# Subsequent CI runs — blocks on unreviewed changes
npx chromatic \
  --project-token=$CHROMATIC_TOKEN \
  --exit-zero-on-changes=false \
  --exit-once-uploaded=false

Configure Chromatic’s threshold in chromatic.config.json alongside the project root:

{
  "projectId": "Project:abc123",
  "buildScriptName": "build-storybook",
  "diffThreshold": 0.001,
  "autoAcceptChanges": "main",
  "exitZeroOnChanges": false,
  "onlyChanged": true
}

onlyChanged: true is critical for large design systems: it instructs Chromatic to snapshot only the stories in files affected by the current commit, keeping CI times sub-60 seconds even when you have hundreds of stories.

Verify: After the first run, open the Chromatic build URL printed in the terminal. Every story should appear with a green “New baseline” badge.


Step 6 — Wire CI gating in GitHub Actions

Intent: Block pull requests automatically when Chromatic detects unreviewed visual changes. Combine this with the baseline management workflow to keep the review queue manageable.

# .github/workflows/chromatic.yml
name: Chromatic Visual Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  chromatic:
    name: Run Chromatic
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0        # full history required for Chromatic change detection

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci --prefer-offline

      - name: Run Chromatic
        uses: chromaui/action@v11
        with:
          projectToken: ${{ secrets.CHROMATIC_TOKEN }}
          buildScriptName: build-storybook
          exitZeroOnChanges: false     # fail CI on unreviewed changes
          onlyChanged: true            # scope snapshots to changed stories
          autoAcceptChanges: main      # auto-accept on merge to main

Verify: Open a pull request that changes a CSS token. The chromatic job should fail with Build has X unreviewed changes until you approve the diffs in the Chromatic UI.


Configuration reference

Option Type Default Effect
diffThreshold number 0 Minimum pixel-change ratio (0–1) before a change is flagged. 0.001 suppresses sub-pixel rendering noise.
autoAcceptChanges string | boolean false Branch glob or true to accept all changes without review. Use "main" to auto-accept only on the trunk.
onlyChanged boolean false Snapshot only stories in changed files. Requires full git history (fetch-depth: 0).
exitZeroOnChanges boolean true When false, exits with code 1 if unreviewed changes exist — required for hard CI gating.
buildScriptName string "build-storybook" npm script that produces the storybook-static/ directory.
storybookBaseDir string "." Relative path to the root that contains package.json and node_modules in a monorepo.
argTypes.control object {} Storybook control UI shape. { type: 'select' } renders a dropdown; { type: 'radio' } renders inline radios.
parameters.chromatic.disableSnapshot boolean false Skip Chromatic snapshot for a specific story. Use only for utility/loader stories with no visual output.
parameters.chromatic.modes object {} Map of named viewport/theme configs. Each entry generates an additional snapshot without a separate story export.

Common pitfalls

1. Using random or Date.now() values inside stories. Any non-deterministic output in a story’s render — timestamps, Math.random(), crypto.randomUUID() — causes Chromatic to flag every build as changed. Mock these at the story level using parameters.msw (via MSW Storybook addon) or pass fixed values through args.

2. Forgetting to reset animation state before snapshot capture. CSS transitions and keyframe animations produce mid-frame snapshots. Add parameters: { chromatic: { pauseAnimationAtEnd: true } } to your global story config in .storybook/preview.ts to freeze all animations at their final frame before capture.

3. Relying on browser fonts for pixel-perfect baselines. System font rendering differs between Linux CI runners and macOS developer machines, producing false positives on every text-containing story. Embed a web font via a global decorator, or disable font anti-aliasing in Chromatic’s --force-fonts flag.

4. Authoring a single AllVariants story instead of named exports. A single story that renders all variants side-by-side produces one large snapshot diff when any variant changes, making it impossible to identify which variant regressed. Name each variant separately so Chromatic’s diff view stays scoped to the changed variant.

5. Setting autoAcceptChanges: true globally. This silently accepts every change on every branch and defeats the purpose of a CI gate. Use autoAcceptChanges: "main" to restrict auto-acceptance to the trunk integration branch only.


Integration points

Component variants are a mid-point in the broader quality workflow:

  • Upstream — argtable mapping: The argTypes configuration on this page is a direct application of the prop-mapping patterns described there. Complex prop shapes (discriminated unions, polymorphic as props) need that treatment before a clean variant matrix is possible.
  • Upstream — isolation principles: Each story must enforce an isolation boundary. If a variant story imports a global store or a React context that isn’t provided by a decorator, snapshots become non-deterministic.
  • Downstream — visual regression strategies: The baselines captured in Step 5 feed into the diff algorithms, tolerance thresholds, and cross-browser matrix covered in that section of the site.
  • Downstream — interaction testing: The play function added in Step 4 is documented more fully there, including patterns for multi-step interactions and asserting on accessible roles.
  • Downstream — dynamic variant generation: Once the named-story approach is established, you can layer in automated matrix expansion for components with large prop surfaces.

FAQ

How many stories should I write per component?

One story per meaningful visual state: the default, each named variant, each interactive state (loading, error, disabled), and any edge-case data shapes (empty label, 100-character truncation, RTL text). Avoid exhaustive combinatorial explosion — cover states that differ visually, not every mathematical permutation of your prop matrix.

Can I reuse a single args object across all variant stories?

Yes. Export a shared baseArgs constant from your stories file and spread it into each story’s args, then override only the keys that differ. This keeps the matrix DRY and prevents silent baseline drift when a shared default changes — a single edit to baseArgs propagates TypeScript errors to every story that depends on it.

Why does Chromatic capture an extra snapshot even when nothing changed?

Chromatic re-captures when the story’s args, decorators, or any imported module hash changes between builds. Ensure your build is reproducible: pin all dependency versions with npm ci, disable Storybook telemetry, and avoid injecting environment-specific values (e.g. process.env.BUILD_ID) into the bundle.

How do I handle dark-mode variants without doubling my story count?

Use the modes parameter. In .storybook/preview.ts, define your modes once in parameters.chromatic.modes; Chromatic captures one snapshot per mode automatically without you authoring separate stories. This scales cleanly to three or more themes (light, dark, high-contrast) with zero additional story exports.