Visual Regression Testing with Storybook and Chromatic
Visual regression testing catches unintended UI changes by comparing screenshots of your components against known-good baselines. Storybook + Chromatic is the most widely adopted setup for this: Storybook provides isolated component renders across all states, and Chromatic handles screenshot capture, diffing, and the review workflow.
This guide covers everything from initial setup to running visual tests in CI and managing baselines efficiently.
How Storybook + Chromatic Works
The workflow has three parts:
- Stories as test cases — each story is a component state that gets screenshotted
- Chromatic captures — on every CI run, Chromatic renders each story in a cloud browser and takes a screenshot
- Diff review — if pixels changed, Chromatic flags it. A team member reviews and either accepts (new baseline) or rejects (regression)
This approach scales well because you don't write separate visual test scripts — your existing stories double as visual test cases.
Setup
Install Chromatic
npm install --save-dev chromaticGet Your Project Token
Create an account at chromatic.com and connect your repository. Chromatic gives you a project token — keep it in your CI secrets as CHROMATIC_PROJECT_TOKEN.
Run Chromatic
npx chromatic --project-token=<your-token>On first run, Chromatic builds your Storybook, renders every story, and saves baseline screenshots. On subsequent runs, it compares new screenshots to those baselines.
Writing Stories for Visual Testing
Every story is a visual test case. Think about what states matter visually:
// Button.stories.tsx
export const Primary: Story = { args: { variant: 'primary', label: 'Save' } };
export const Secondary: Story = { args: { variant: 'secondary', label: 'Cancel' } };
export const Disabled: Story = { args: { disabled: true, label: 'Save' } };
export const Loading: Story = { args: { loading: true, label: 'Saving...' } };
export const WithIcon: Story = { args: { icon: 'check', label: 'Done' } };Each of these becomes a separate screenshot comparison. If Loading state suddenly renders differently, Chromatic catches it even if the other states look fine.
Controlling Viewport
Test at multiple breakpoints by setting viewport sizes in stories:
export const Mobile: Story = {
parameters: {
viewport: { defaultViewport: 'mobile1' },
chromatic: { viewports: [375, 768, 1280] },
},
};The chromatic.viewports parameter tells Chromatic to capture the story at each listed width.
Disabling Visual Tests for Specific Stories
Some stories change on every render (timestamps, random data, animations). Exclude them from visual diffing:
export const AnimatedLoader: Story = {
parameters: {
chromatic: { disableSnapshot: true },
},
};Handling Animations
Animations cause false positives because screenshots capture a mid-animation frame. Disable animations in Chromatic:
// .storybook/preview.ts
export const parameters = {
chromatic: {
pauseAnimationAtEnd: true, // Wait for CSS animations to complete
},
};For JavaScript animations, pause them explicitly:
export const AnimatedCard: Story = {
parameters: {
chromatic: { delay: 300 }, // Wait 300ms before screenshot
},
};The Review Workflow
When Chromatic detects pixel changes, it creates a build with diffs for review.
What "Changed" Means
Chromatic highlights the exact pixels that changed in red. You see:
- Before — baseline screenshot
- After — new screenshot
- Diff — pixels that changed, highlighted
Changes fall into two categories:
- Intentional — you changed a button color, updated a font. Accept the diff to make it the new baseline.
- Regression — an unintended side effect broke the visual appearance. Reject it.
Accepting Changes
Accept diffs to update the baseline. You can accept:
- A single story's diff
- All diffs in a build
- All diffs matching a component
Once accepted, those screenshots become the new baseline for future comparisons.
PR Integration
Connect Chromatic to your GitHub/GitLab repo and it posts build status to PRs automatically:
- ✅ No visual changes — PR can merge
- ⚠️ Changes detected — review required before merge
- ❌ Build failed — Storybook couldn't render
Configure it to block PR merges until visual review completes:
# GitHub: require the chromatic/ui-review check to passCI Integration
GitHub Actions
name: Visual Testing
on: [push, pull_request]
jobs:
chromatic:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for TurboSnap
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Publish to Chromatic
uses: chromaui/action@latest
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
onlyChanged: true # TurboSnap — only test affected storiesThe fetch-depth: 0 is required for TurboSnap (see below).
GitLab CI
chromatic:
image: node:20
script:
- npm ci
- npx chromatic --project-token=$CHROMATIC_PROJECT_TOKEN
only:
- merge_requests
- mainTurboSnap: Faster CI
TurboSnap analyzes your git changes and only takes snapshots of stories that could be affected by those changes. If you change Button.tsx, only Button.stories.tsx runs — not your entire Storybook.
Enable it with --only-changed:
npx chromatic --project-token=<token> --only-changedOn large Storybooks with hundreds of stories, TurboSnap reduces CI time by 50-80%.
Requirements for TurboSnap:
- Full git history available (
fetch-depth: 0in GitHub Actions) - Storybook configured with
staticDirspointing to your actual assets - No dynamic imports that bypass dependency tracking
Baseline Management
Accepting on Main Branch
Baselines are tied to branches. When you accept a diff on a feature branch, it only updates the baseline for that branch. When the branch merges to main, the accepted baseline becomes the main branch baseline.
This means:
- Feature branch diffs don't pollute main baselines
- Only reviewed, accepted changes become permanent
Rerunning Builds
If you accidentally accept a regression, rerun the previous build to restore that baseline:
npx chromatic --project-token=<token> --rebuild-detectorOr use the Chromatic UI to rebaseline from a specific historical build.
Squash Commits and Rebases
If your repo squashes commits, git history gets rewritten. Tell Chromatic to use the merge commit:
npx chromatic --project-token=<token> --patch-build=<base-branch>Storybook Modes (Matrix Testing)
Chromatic Modes let you test stories across multiple themes, locales, or color schemes without writing separate stories:
// .storybook/modes.ts
export const allModes = {
'light desktop': {
backgrounds: { value: '#ffffff' },
viewport: { width: 1280 },
},
'dark desktop': {
backgrounds: { value: '#1a1a1a' },
viewport: { width: 1280 },
},
'light mobile': {
backgrounds: { value: '#ffffff' },
viewport: { width: 375 },
},
};Apply to individual stories or globally:
export const Card: Story = {
parameters: {
chromatic: {
modes: {
'light desktop': allModes['light desktop'],
'dark desktop': allModes['dark desktop'],
},
},
},
};Chromatic captures one screenshot per mode — you get visual coverage for every combination without duplicating stories.
Cost Management
Chromatic pricing is based on snapshots (screenshots taken per build). To manage costs:
- Use TurboSnap — only test changed components
- Disable snapshots for dynamic stories —
chromatic: { disableSnapshot: true } - Limit viewport testing — don't test all 3 viewports for every story; reserve multi-viewport for layout-sensitive components
- Skip stories on main — only run full snapshots on PRs; use
--skipon merge commits if CI cost is a concern
Comparing with Self-Hosted Alternatives
Chromatic is a paid service. Self-hosted alternatives include:
- Loki — free, stores baselines in your repo, runs locally
- Percy — similar to Chromatic, BrowserStack product
- BackstopJS — open source, more configuration required
- reg-suit — open source, integrates with S3 for baseline storage
Chromatic's advantages are the GitHub integration, managed infrastructure, and the review workflow being purpose-built for visual testing. For teams that want to avoid SaaS costs, Loki with Storybook is a viable alternative — see the Loki guide for setup.
When Visual Regression Testing Pays Off
Visual regression testing is most valuable when:
- You have a component library or design system used across many apps
- Multiple developers touch UI code simultaneously
- You ship frequently and can't manually verify every component after each deploy
- Your components have many visual states (loading, error, empty, disabled, hover)
The upfront investment is low — your stories already exist. Adding Chromatic is a one-line CI change. The payoff is catching accidental regressions before users do.