Chromatic Visual Testing: Catching UI Regressions in Storybook
Visual regressions are sneaky. A CSS change that looks harmless in isolation can break a button layout three components away. Chromatic solves this by capturing pixel-perfect snapshots of every Storybook story and comparing them against a known baseline — automatically, on every pull request.
This guide walks through setting up Chromatic from scratch, configuring baselines, reviewing diffs, and integrating with your CI pipeline.
What Is Chromatic and How Does It Work
Chromatic is a cloud-based visual testing service built specifically for Storybook. When you run a Chromatic build, it renders every story in a real browser (Chrome, Firefox, and Safari if configured), captures a screenshot, and compares it against the accepted baseline.
The key concept is the baseline — the accepted visual state of a component. If a story's screenshot differs from its baseline by more than a configurable threshold, Chromatic flags it as a change that requires human review. Changes that are intentional get "accepted" and become the new baseline. Unintentional ones get rejected, which blocks the PR.
This workflow integrates tightly with GitHub, GitLab, and Bitbucket via status checks, so a PR with unreviewed visual changes simply cannot be merged.
Installing and Configuring Chromatic
Start by installing the Chromatic CLI:
npm install --save-dev chromaticYou'll need a project token from the Chromatic dashboard (chromatic.com). Create a project linked to your repository, and copy the token.
Add a script to package.json:
{
"scripts": {
"chromatic": "chromatic --project-token=<your-token>"
}
}For CI, store the token as a secret and pass it via environment variable:
npx chromatic --project-token=$CHROMATIC_PROJECT_TOKENOn the first run, Chromatic uploads your Storybook build, renders every story, and establishes baselines. There's nothing to compare against yet, so every story is auto-accepted.
Setting Up Baseline Snapshots
Baselines are per-story, per-branch, per-browser. When you create a new story, the first successful build establishes that story's baseline on that branch.
To run a baseline build explicitly:
npx chromatic --project-token=$CHROMATIC_PROJECT_TOKEN --auto-accept-changesThe --auto-accept-changes flag accepts all detected changes without manual review. Use this when you're intentionally updating the baseline — for example, after a design system update that touches many components at once.
You can also auto-accept changes on a specific branch:
npx chromatic --project-token=$CHROMATIC_PROJECT_TOKEN --auto-accept-changes="main"This auto-accepts on main but requires review on feature branches, which is a common setup.
Reviewing Diffs and the PR Workflow
When Chromatic detects a visual change, it posts a status check to your pull request. Clicking through takes you to the Chromatic UI where you can review each diff side by side.
The diff view shows three panels: the baseline snapshot, the new snapshot, and a diff overlay that highlights changed pixels. Green pixels are additions, red pixels are removals. You can toggle between a "1-up" comparison and a split view.
For each changed story you have two options:
- Accept — this change is intentional. The new snapshot becomes the baseline.
- Deny — this is a regression. The PR check stays red.
You can accept or deny individual stories, or bulk-accept all changes in a build if you've done a broad intentional refactor.
Once all changes are reviewed and accepted (or denied with fixes committed), the PR check turns green.
Configuring Story-Level Options
Chromatic respects story-level parameters for fine-grained control:
// Button.stories.ts
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
parameters: {
chromatic: {
// Delay snapshot by 300ms to let animations settle
delay: 300,
// Disable this story from Chromatic
disableSnapshot: false,
// Test in multiple viewports
viewports: [320, 768, 1280],
},
},
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
args: { variant: 'primary', children: 'Click me' },
};
export const Loading: Story = {
args: { variant: 'primary', loading: true },
parameters: {
chromatic: {
// Loading spinners are animated — pause before snapshot
pauseAnimationAtEnd: true,
},
},
};The viewports parameter is particularly useful — it creates separate snapshots for each viewport width, catching responsive layout regressions without any additional test code.
CI Integration and Auto-Accept Workflow
Here's a complete GitHub Actions workflow for Chromatic:
# .github/workflows/chromatic.yml
name: Chromatic
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 # Required for Chromatic to detect changes
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Run Chromatic
uses: chromaui/action@latest
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
autoAcceptChanges: ${{ github.ref == 'refs/heads/main' }}
exitZeroOnChanges: false
onlyChanged: trueThe onlyChanged: true flag is a performance optimization — it only snapshots stories that have changed files in the PR, skipping the rest. For large Storybook projects with hundreds of stories, this can cut build time dramatically.
exitZeroOnChanges: false ensures the CI job fails when there are unreviewed changes, blocking the PR merge.
Handling Flaky Snapshots
Dynamic content — timestamps, animations, random data — causes false positives. Address these with a few strategies:
Ignore regions at the story level:
export const WithTimestamp: Story = {
parameters: {
chromatic: {
// Mask a region by CSS selector
diffIncludeAntiAliasing: false,
},
},
decorators: [
(Story) => (
<div>
<Story />
{/* Mark dynamic content so Chromatic ignores it */}
<span data-chromatic="ignore">Last updated: {new Date().toLocaleString()}</span>
</div>
),
],
};Any element with data-chromatic="ignore" is painted over with a solid color before snapshotting.
Disable CSS animations globally via Storybook's preview.js:
// .storybook/preview.ts
export const parameters = {
chromatic: {
pauseAnimationAtEnd: true,
},
};This freezes CSS transitions and animations at their end state, giving consistent snapshots even for animated components.
What to Do When Things Go Wrong
Build fails with "no stories found" — Chromatic can't locate your built Storybook. Make sure you're running build-storybook before the Chromatic step, or use the buildScriptName option to point Chromatic at your build script.
Snapshots look different on CI vs local — font rendering differs between operating systems. Chromatic renders in a consistent Linux environment, so local comparisons will never match exactly. Always use the Chromatic UI for reviews, not local comparisons.
Too many changes after a dependency upgrade — use --auto-accept-changes on a dedicated update branch, merge to main, and the new baselines will propagate to all subsequent feature branches.
Chromatic transforms visual regression testing from a manual checklist into an automated gate. Once integrated, every PR either has clean snapshots or requires an explicit human decision — nothing slips through unreviewed.