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.
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
argTypesconfiguration on this page is a direct application of the prop-mapping patterns described there. Complex prop shapes (discriminated unions, polymorphicasprops) 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
playfunction 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.
Related
- Storybook Isolation Workflows — parent section covering the full Storybook-based testing discipline
- Argtable Mapping — how to model complex prop shapes as Storybook argTypes
- Interaction Testing — drive components into interactive states before snapshot capture
- Creating Dynamic Component Variants in Storybook 8 — runtime matrix expansion for large prop surfaces
- Visual Regression Snapshot Strategies — baseline capture, diff algorithms, and cross-browser matrix execution
- Baseline Management — how to keep the Chromatic review queue from accumulating unreviewed drift