Chromatic Visual Testing: Storybook Snapshot Diffing for Components
Chromatic catches visual regressions before your users do — here's how to wire Storybook snapshot diffing into your CI pipeline and stop shipping broken components.
What Chromatic Actually Does (and Why You Need It)
Visual testing has a reputation problem. Developers know they should do it, they set it up once, it flaps constantly, they disable it, and then six months later someone's button text is white-on-white in production. Chromatic exists specifically to break that cycle.
Here's the pitch in one sentence: Chromatic runs your Storybook stories in a cloud browser, captures pixel-perfect screenshots, and diffs them against the last approved baseline on every pull request. You review the diff in a UI, click Approve or Deny, and your CI gate passes or fails accordingly. That's it.
Worth noting: Chromatic was built by the same team that maintains Storybook, so the integration is genuinely tight — not bolted on. As of Chromatic v11 (released late 2025), they also support Playwright and Cypress component stories, so you're not locked into Storybook's specific story format if you've already moved on.
In practice, the real value isn't catching dramatic breakages — your unit tests already catch those. It's catching the *quiet* regressions: a Tailwind purge that drops a utility you depended on, a rem value that shifted because someone changed the root font-size from 16px to 14px, a shadow that disappeared after a dependency bump. Those are invisible to Jest. Chromatic finds them.
Setting Up Chromatic in Under 10 Minutes
You need an existing Storybook setup. If you don't have one yet, the Storybook component library guide on the Empire UI blog covers that from scratch. Once you have stories running locally, the Chromatic setup is genuinely fast.
Install the package and run your first build:
``bash
npm install --save-dev chromatic
npx chromatic --project-token=<your-token>
``
You get the token by creating a free Chromatic account and linking your GitHub/GitLab repo. That first run uploads your stories and establishes the baseline — every pixel captured becomes the "truth" that future runs compare against.
The chromatic CLI builds your Storybook internally (it runs build-storybook for you), pushes the static files to Chromatic's cloud, spins up browsers, and captures. A medium-sized library with 80 stories typically takes 2–4 minutes on their free tier. That's fast enough to block a PR without annoying your team.
One more thing — add a .chromatic config file to avoid repeating CLI flags:
``js
// chromatic.config.js
/** @type {import('chromatic/config').Config} */
export default {
projectId: 'your-project-id',
// Only run on changed stories + their dependencies
onlyChanged: true,
// Capture at 1280px wide by default
viewports: [375, 768, 1280],
// Fail CI if any new changes are unreviewed
exitZeroOnChanges: false,
};
`
onlyChanged: true` is the single biggest performance win — Chromatic traces which stories use which components via a module graph and only re-renders stories that could be affected by your diff. On large codebases this cuts run time by 60–80%.
Wiring Chromatic into GitHub Actions CI
Running Chromatic locally is useful during development, but the real payoff is blocking merges when visual regressions appear. Here's a minimal GitHub Actions workflow that does exactly that:
# .github/workflows/chromatic.yml
name: Chromatic
on:
push:
branches-ignore:
- main
pull_request:
jobs:
chromatic:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Chromatic needs full git history for baseline tracking
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Run Chromatic
uses: chromaui/action@latest
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
onlyChanged: true
exitZeroOnChanges: falseA few things worth knowing about this setup. The fetch-depth: 0 is non-negotiable — Chromatic uses git history to identify your baseline commit, and a shallow clone breaks that entirely. I've seen teams waste 45 minutes debugging "baseline not found" errors that were caused by a missing fetch-depth: 0 flag.
The exitZeroOnChanges: false flag makes CI fail when there are unreviewed visual changes. You probably want this. Some teams prefer exitZeroOnChanges: true during the initial rollout period (so Chromatic reports but doesn't block) and then flip the flag once the team has reviewed the backlog. Both are valid strategies.
Quick aside: Chromatic's GitHub App integration adds inline UI diffs directly to your pull request, which is where the real magic happens for code reviewers. Without it you're just clicking a link to the Chromatic dashboard. Install the app from your Chromatic project settings — it takes 30 seconds and changes the review workflow dramatically.
Writing Stories That Produce Useful Snapshots
A visual test is only as good as the story it tests. If your stories are thin — just <Button>Click me</Button> with default props — you'll get shallow coverage. The regressions that matter usually live in specific states: disabled, loading, error, hovered, focused.
Write stories that cover edge cases explicitly:
``tsx
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
component: Button,
// 375px mobile + 1280px desktop snapshots for every story
parameters: {
chromatic: { viewports: [375, 1280] },
},
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Default: Story = {};
export const Disabled: Story = {
args: { disabled: true },
};
export const Loading: Story = {
args: { loading: true },
};
// Snapshot the hover state by forcing the CSS pseudo-class
export const Hovered: Story = {
parameters: {
pseudo: { hover: true },
},
};
export const LongLabel: Story = {
args: { children: 'This is a very long button label that might overflow or wrap unexpectedly' },
};
``
The storybook-addon-pseudo-states package (referenced above via pseudo: { hover: true }) is one of the most underused tools in the Storybook ecosystem. It forces CSS pseudo-classes like :hover, :focus, and :active without needing real pointer events, which means Chromatic captures those states reproducibly across every run.
Honestly, the discipline of writing thorough stories pays off beyond visual testing. When you force yourself to cover Disabled, Loading, Error, and LongLabel states in Storybook, you find bugs that would have taken days to reproduce in a live app. The visual test is a side-effect of good story hygiene.
If you're building components with expressive styles — glassmorphism components, neobrutalism cards, aurora effects — the visual snapshot is particularly valuable because the aesthetic is all about exact pixel values. A 2px blur difference on a backdrop-filter might be invisible in code review but immediately obvious in a Chromatic diff.
Snapshot Diffing: How Chromatic Detects Changes
Chromatic's diffing algorithm is pixel-based, not DOM-based. It captures a PNG of the rendered story, compares it to the baseline PNG pixel by pixel, and highlights any region where the RGBA values differ beyond a configurable threshold. This is different from tools like Percy, which offered a DOM-snapshot approach — pixel comparison catches more classes of regression but is also more sensitive to antialiasing and font rendering variance.
Chromatic handles font rendering variance automatically by running all captures in the same controlled headless Chrome environment on their infrastructure. You're not comparing a screenshot from your Mac against a screenshot from a Linux CI runner — both the baseline and the comparison run in the same browser. That's a bigger deal than it sounds; it's why Chromatic has significantly fewer false positives than rolling your own Playwright screenshot tests.
You can tune the diff sensitivity per story with the diffThreshold parameter:
``tsx
export const GlassmorphismCard: Story = {
parameters: {
chromatic: {
// Allow up to 0.2 antialias variance (0–1 scale)
diffThreshold: 0.2,
// Delay capture to let CSS animations settle
delay: 300,
},
},
};
`
The delay option is critical for animated components. If your story has a fade-in that takes 200ms, Chromatic's default 0ms capture will catch the animation mid-flight and produce false positives on every run. Set delay` to something comfortably past the animation duration.
Worth noting: Chromatic also supports "TurboSnap" as their branded name for the onlyChanged module tracing. If a PR only touches Button.tsx, TurboSnap ensures only Button stories re-render — not your entire 300-story library. The module graph it builds is surprisingly accurate; it traces CSS imports, utility re-exports, and context providers correctly in most cases.
Integrating Chromatic with a Design System Workflow
Where Chromatic really earns its place is in a design system with multiple consumers. You build a component, your designer approves the Storybook story, Chromatic captures the baseline. Six weeks later a dependency upgrade subtly changes the font rendering on your <Heading> component. Without visual testing, that ships to 12 internal apps before anyone notices.
The approval workflow maps directly onto design review. Designers can log into the Chromatic UI, see exactly what changed visually (not in code), and click Approve. No need to run anything locally, no reading diffs. This is the workflow that actually gets design sign-off happening in the same pull request as the code change — rather than in a Slack thread three days later.
If you're building on Empire UI's component system, you can snapshot the full set of style variants systematically. Consider a story file that iterates over your theme tokens:
``tsx
// AllStyles.stories.tsx
import { Card } from './Card';
const styles = ['glassmorphism', 'neumorphism', 'neobrutalism', 'cyberpunk'] as const;
export default { title: 'Visual Regression/AllStyles' };
export const StyleMatrix = () => (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 24 }}>
{styles.map(style => (
<Card key={style} variant={style}>
{style}
</Card>
))}
</div>
);
``
One story, four variants, one snapshot. If anything shifts across your glassmorphism, neumorphism, neobrutalism, or cyberpunk style layers, Chromatic catches it in one diff.
Look, the practical advice here is: start with your highest-traffic components and your most visually sensitive ones. A Button change is low risk. A design token change that cascades through 40 components is high risk. Focus your story coverage where regressions would be expensive to find in production, and let Chromatic do the mechanical checking.
Common Pitfalls and How to Fix Them
The most common issue teams hit is dynamic content in stories — dates, random data, API responses. Chromatic will diff a timestamp that changed between runs and flag it as a visual change. Fix this with deterministic mocks:
``tsx
// Use a fixed date, not new Date()
export const WithTimestamp: Story = {
args: {
date: new Date('2026-01-15T10:30:00Z'),
},
};
// Or freeze Math.random in your decorators
const withDeterministicSeed = (Story: React.ComponentType) => {
Math.random = () => 0.5; // Always returns 0.5
return <Story />;
};
``
The second most common pitfall is ignoring the "accepted" vs "approved" baseline distinction. When you first run Chromatic, all stories auto-accept. But after that, any change requires a human review. Teams sometimes set autoAcceptChanges: 'main' to auto-accept on the main branch — that's fine for intentional redesigns, but make sure it's a deliberate choice and not just a workaround for noisy baselines.
External fonts are another classic false-positive source. If your story loads a Google Font via a network request and the Chromatic environment blocks that request (it does, for reproducibility), you'll get fallback font rendering on every story that uses it. Fix this by hosting your fonts locally in Storybook's public folder and referencing them in .storybook/preview-head.html. The Google Fonts performance guide on the blog covers self-hosting in detail.
One more thing — if you're seeing consistent 1–2px offsets in diffs that you can't explain, check whether you have deviceScaleFactor mismatches. Chromatic captures at 1x DPR by default. If your local screenshots are captured at 2x (retina), the baselines won't match until you standardize. Set the forcedColors and scale in your Chromatic config and you'll stop chasing phantom regressions.
FAQ
Chromatic has a free tier that includes 5,000 snapshots per month, which is enough for small projects or early design systems. Paid plans start at around $149/month for unlimited snapshots and team features.
As of v11, Chromatic supports Playwright and Cypress component tests in addition to Storybook stories. That said, the Storybook integration is the most mature and the one with full design-review workflow support.
Chromatic runs in a controlled cloud environment so baselines and comparisons are always captured in the same browser — eliminating OS-level font and antialiasing variance. It also has a built-in review UI, baseline management, and module-graph tracing to skip unaffected stories.
Yes — it captures the fully rendered pixel output, so it doesn't matter whether styles come from Tailwind classes, CSS Modules, styled-components, or inline styles. If the visual output changes, Chromatic catches it.