Chromatic Visual Regression Testing with Storybook
CSS regressions are invisible to unit tests and integration tests. A button becomes 2px too small. A card's padding collapses on a specific breakpoint. A dark mode color turns slightly wrong. These are real bugs that reach production because no test caught them — there was nothing to assert on.
Chromatic solves this by capturing pixel-perfect snapshots of every Storybook story and comparing them against baselines. Any visual change, intended or accidental, shows up for review before it merges.
How Chromatic Works
The workflow is:
- Chromatic builds your Storybook
- It renders each story in a cloud browser (Chromium, Firefox, Safari — depending on your plan)
- It takes a pixel-perfect screenshot of each rendered story
- On subsequent runs, it diffs the new screenshot against the stored baseline
- Changed stories appear in a visual UI review queue
- Reviewers accept (update baseline) or reject (block the PR)
This is different from snapshot testing in Jest (toMatchSnapshot()), which compares serialized React component trees. Chromatic compares actual rendered pixels — it catches CSS changes that produce identical component trees.
Setting Up Chromatic
npm install --save-dev chromaticGet a project token from chromatic.com. Create an account, connect your GitHub repository, and copy the project token.
First publish:
npx chromatic --project-token=<your-project-token>Chromatic builds your Storybook, captures snapshots, and establishes baselines. On the first run, all stories pass — there's nothing to compare against yet.
CI Integration
# .github/workflows/chromatic.yml
name: Chromatic
on:
push:
branches-ignore:
- main
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'
cache: 'npm'
- run: npm ci
- name: Publish to Chromatic
uses: chromaui/action@latest
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
onlyChanged: true
exitZeroOnChanges: true # Don't fail CI on UI changes, just flag themThe exitZeroOnChanges: true flag prevents Chromatic from failing CI when it detects visual changes. Instead, it marks the GitHub commit check as "UI changes detected" and waits for a reviewer to accept or reject in the Chromatic UI. This is the recommended default — you don't want unreviewed visual changes blocking deploys indefinitely just because a reviewer hasn't looked yet.
Remove this flag if you want strict mode where any unreviewed change blocks the PR.
The Visual Review Workflow
When Chromatic detects changes, it posts a status check on the GitHub PR. Click through to the Chromatic build page to see:
- Snapshots: Side-by-side comparison of old and new rendering
- Diff mode: Highlighted pixels that changed (orange = changed, green = added, red = removed)
- Story list: All changed stories in this build, grouped by component
For each changed story, you choose:
- Accept: Update the baseline to the new screenshot. This is a deliberate change.
- Deny: Reject the change. This flags it as a regression.
Accepted changes become the new baseline for future comparisons. Denied changes show as failing in subsequent builds until the regression is fixed.
Branch Detection and Baselines
Chromatic tracks baselines per branch. When you create a feature branch, Chromatic uses the main branch baseline as a starting point. Changes you make on the feature branch are compared against main, not against other feature branches.
When a PR merges to main, the accepted baselines from the feature branch are promoted to become the new main baseline. This means:
- You never need to manually "sync" baselines when merging
- Reviewers on feature branch PRs see only the changes that branch introduces
- Baseline drift (where baselines slowly diverge from the intended design) is impossible — changes always trace back to a reviewer acceptance
Configure the base branch in Chromatic's project settings if you use a non-main default branch.
TurboSnap: Only Testing Changed Stories
For large Storybook projects, testing every story on every PR is slow and expensive. TurboSnap analyzes which stories changed (based on webpack/Vite dependency graphs) and only captures new snapshots for affected stories.
- name: Publish to Chromatic
uses: chromaui/action@latest
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
onlyChanged: true # Enable TurboSnapTurboSnap requires fetch-depth: 0 in the checkout step so it can analyze git history to determine changed files.
What TurboSnap traces:
- The story file itself changed → test the story
- A component imported by the story changed → test the story
- A CSS/style file imported by the component changed → test the story
- A utility function used by the component changed → test the story
If a change is in a shared dependency (like a design token file), TurboSnap may test all stories that import that file. This is correct behavior — a design token change should be reviewed across all components.
TurboSnap typically reduces snapshot counts by 50-80% on large projects. At scale (thousands of stories), this is the difference between a 2-minute CI check and a 15-minute one.
Configuring Story Snapshots
Control snapshot behavior per-story with parameters:
// Skip snapshot for a story (e.g., animation-heavy stories)
export const LoadingSpinner: Story = {
parameters: {
chromatic: { disableSnapshot: true },
},
};
// Test multiple viewports
export const ResponsiveCard: Story = {
parameters: {
chromatic: {
viewports: [320, 768, 1280],
},
},
};
// Increase diff threshold for stories with animations
export const AnimatedButton: Story = {
parameters: {
chromatic: {
diffThreshold: 0.3, // default is 0.063 (6.3%)
pauseAnimationAtEnd: true,
},
},
};
// Dark mode snapshot
export const DarkModeCard: Story = {
parameters: {
backgrounds: { default: 'dark' },
chromatic: {
theme: 'dark',
},
},
};Handling Flaky Snapshots
Animated components, date/time displays, and network-dependent content cause flaky snapshots. Address each:
Animations: Use pauseAnimationAtEnd: true to let animations complete before the snapshot, or disableSnapshot: true for stories where the animated state is the point.
Dates/times: Mock Date.now() in the story decorator:
// .storybook/preview.tsx
import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport';
const withMockedDate = (Story) => {
// Mock Date to a fixed value for consistent snapshots
const OriginalDate = global.Date;
global.Date = class extends OriginalDate {
constructor(...args) {
if (args.length === 0) {
super('2024-01-15T10:00:00Z');
} else {
super(...args);
}
}
static now() {
return new OriginalDate('2024-01-15T10:00:00Z').getTime();
}
};
return <Story />;
};
export const decorators = [withMockedDate];External images: Use static fixtures in story args, not real API URLs.
Random data: Seed your faker/random data generators with a fixed seed in story files.
Pricing and Plan Considerations
Chromatic pricing is based on snapshots per month:
- Free: 5,000 snapshots/month — enough for a small project (50 stories × 2 builds/day × 5 days/week × 4 weeks ≈ 2,000 snapshots)
- Starter ($149/mo): 35,000 snapshots
- Team ($499/mo): 150,000 snapshots + multi-browser testing
With TurboSnap, snapshot usage often drops 60-80%, making the free and starter tiers go much further than the raw numbers suggest.
For open-source projects, Chromatic is free with unlimited snapshots.
Calculate your expected usage:
(number of stories) × (builds per day) × (working days per month) × (viewports)
= monthly snapshot estimateFor a 200-story project running Chromatic on every PR push (say, 10 builds/day on average) with 2 viewports: 200 × 10 × 20 × 2 = 80,000 snapshots/month. TurboSnap brings this to ~16,000–40,000 depending on how many stories change per build.
Combining Chromatic with Play Functions
When a story has a play function, Chromatic runs the play function before capturing the snapshot. This means your visual snapshot shows the component in its post-interaction state.
export const SubmittedContactForm: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText('Name'), 'Jane Smith');
await userEvent.type(canvas.getByLabelText('Email'), 'jane@example.com');
await userEvent.type(canvas.getByLabelText('Message'), 'Hello!');
await userEvent.click(canvas.getByRole('button', { name: /send/i }));
},
};Chromatic's snapshot of SubmittedContactForm shows the form in its success state, not the initial empty state. This gives you visual regression coverage on interaction outcomes, not just initial renders.
Self-Hosted Alternative: reg-suit
If Chromatic's pricing or data residency requirements don't work for you, reg-suit is an open-source alternative:
npm install --save-dev reg-suit
npx reg-suit initreg-suit uses Storycap (headless Puppeteer) to capture screenshots and stores baselines in S3, GCS, or your own storage. It's free to self-host but requires more setup than Chromatic.
The review workflow is less polished than Chromatic's UI — you get an HTML report rather than a dedicated review application. For most teams, Chromatic's free tier is the better starting point.