Chromatic Visual Testing: Catch UI Regressions Before They Ship
Chromatic and Storybook catch pixel-level UI regressions automatically. Here's how to wire up visual testing in your React + Tailwind component library in under an hour.
Why Visual Regressions Are Sneaky and Unit Tests Miss Them
Honestly, unit tests lie to you. They'll tell you everything passes right before you ship a button where the label is invisible because someone changed a background token from #1a1a2e to #ffffff and nobody caught it. The logic is fine. The component is broken.
Visual regressions are a specific category of bug that unit and integration tests literally cannot detect. A padding value changes from 8px to 0px. A z-index collision pushes a dropdown behind a modal. A font-weight update turns a bold CTA into regular text. All of these pass your Jest suite without a single complaint.
That's where Chromatic comes in. It captures pixel-level screenshots of your Storybook stories on every PR, diffs them against a baseline, and blocks merges when something changes unexpectedly. It's a fundamentally different class of testing, and once you've had it catch one regression that would have gone to prod, you won't want to work without it.
The good news is that if you already have Storybook set up — or are building a component library like Empire UI — adding Chromatic takes maybe 45 minutes the first time.
Setting Up Storybook 8.x in a React + Tailwind Project
If you're starting fresh, Storybook 8.x (released early 2025) is what you want. The setup experience is noticeably cleaner than v7 and it handles Tailwind v4.0.2 without the manual PostCSS workarounds you used to need.
Run npx storybook@latest init in your project root. It'll detect your framework — Next.js, Vite, whatever — and scaffold the right config. For Tailwind, you'll need to import your global CSS inside .storybook/preview.ts so the utility classes actually render in your stories.
// .storybook/preview.ts
import type { Preview } from '@storybook/react';
import '../src/styles/globals.css'; // your Tailwind entry point
const preview: Preview = {
parameters: {
backgrounds: {
default: 'dark',
values: [
{ name: 'dark', value: '#0f0f17' },
{ name: 'light', value: '#ffffff' },
{ name: 'glass', value: 'rgba(255,255,255,0.15)' },
],
},
layout: 'centered',
},
};
export default preview;The backgrounds config matters a lot for visual testing. If your components use glassmorphism effects — say you've been playing with glassmorphism generators — you need to snapshot them against the right backdrop or the diff noise will be unbearable.
Installing Chromatic and Publishing Your First Build
Chromatic is a paid SaaS product but they have a generous free tier: 5,000 snapshots per month, which is plenty for most teams to evaluate. You sign up at chromatic.com, link your GitHub repo, and they give you a project token.
Installation is one package: npm install --save-dev chromatic. Then you add a script to package.json and run it. First publish takes a few minutes because it's building your entire story set as baselines.
# package.json scripts
"chromatic": "chromatic --project-token=YOUR_TOKEN"
# run it
npm run chromaticAfter the first run, every subsequent publish diffs against those baselines. Changed stories show up in the Chromatic UI as a side-by-side comparison — old on the left, new on the right, with changed pixels highlighted in green. You accept or deny each change. Accepted changes become the new baseline. Denied changes block the PR.
Writing Stories That Actually Catch Regressions
Here's where most teams go wrong: they write one story per component and call it done. That's not nearly enough. You need stories for every meaningful visual state — hover, focus, loading, error, dark mode, different viewport sizes, different content lengths.
Think about a button component. At minimum you want: default, hover (use the play function), disabled, loading spinner state, icon-only, full-width. If you're using Tailwind shadows or custom drop shadows, you want a story that renders on both light and dark backgrounds so Chromatic can snapshot both.
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'UI/Button',
component: Button,
parameters: {
// snapshot both backgrounds
chromatic: { modes: {
light: { backgrounds: { value: '#ffffff' } },
dark: { backgrounds: { value: '#0f0f17' } },
}},
},
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Default: Story = { args: { children: 'Click me' } };
export const Disabled: Story = { args: { children: 'Click me', disabled: true } };
export const Loading: Story = { args: { children: 'Saving…', loading: true } };
export const Destructive: Story = { args: { children: 'Delete', variant: 'danger' } };The chromatic: { modes: {} } parameter is from Chromatic's modes feature — it snapshots the same story multiple times in different configurations. This is how you catch regressions that only appear in dark mode, which is exactly the kind of thing that slips through normal review.
Wiring Chromatic Into Your CI Pipeline
Running Chromatic locally is nice for exploring, but the real value is in CI. Every PR should trigger a Chromatic build automatically. If there are visual changes, Chromatic leaves a PR check in GitHub that blocks merging until someone reviews and accepts or rejects the diffs.
Here's a minimal GitHub Actions workflow. Note that you should store your Chromatic project token as a repository secret — never hardcode it.
# .github/workflows/chromatic.yml
name: Chromatic Visual Tests
on: [push, pull_request]
jobs:
chromatic:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # required for Chromatic baseline tracking
- 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 }}
exitZeroOnChanges: true # don't fail CI for expected changesThe fetch-depth: 0 is not optional — Chromatic needs the full git history to correctly track which commit introduced a regression. Leave it out and you'll get confusing results. The exitZeroOnChanges: true flag means CI itself doesn't fail just because there are visual changes; it just requires a human to review them in the Chromatic UI before the PR check passes.
Managing Baselines When You Intentionally Change Design
One thing nobody warns you about: once Chromatic is running, intentional design changes create review overhead. You update a gradient generator component to use a new color ramp, and suddenly you have 47 changed snapshots waiting for approval. That's not a bug — that's the system working — but it can feel like friction.
The solution is to be deliberate about how you accept changes. Don't just click 'Accept All' on every PR without looking. Train your team to actually review the diffs. A changed shadow value from 0 4px 6px -1px rgba(0,0,0,0.1) to 0 4px 6px -1px rgba(0,0,0,0.2) is worth noticing even if it was intentional, because it tells you the scope of the change.
For large design system overhauls, Chromatic's squash mode is useful — it lets you accept an entire set of changes at once when you're doing a planned design refresh rather than fixing a bug. Use it deliberately, not as a way to skip review.
Visual Testing for Theme Variants and Dark Mode Components
If your components support a theme toggle — switching between light and dark mode — you need Chromatic to snapshot both. The Chromatic modes feature handles this exactly. But you also need to make sure your Storybook decorator actually applies the theme class to the document root, not just the story container.
A lot of dark mode implementations (including the Tailwind dark: class approach) rely on a .dark class on <html>. If your Storybook stories don't apply that class, your dark mode stories will render in light mode and the snapshots will be meaningless.
// .storybook/preview.ts — dark mode decorator
import { useEffect } from 'react';
const withTheme = (Story, context) => {
const { backgrounds } = context.globals;
useEffect(() => {
const isDark = backgrounds?.value === '#0f0f17';
document.documentElement.classList.toggle('dark', isDark);
}, [backgrounds]);
return <Story />;
};
export const decorators = [withTheme];Once this is in place, Chromatic will snapshot your components in actual dark mode — with the right background tokens, the right text colors, the right border values. It's the difference between testing your theme and pretending to test it. If you're building components with effects like glassmorphism, this step is non-negotiable.
Snapshot Costs, Performance, and Keeping Builds Fast
Chromatic bills per snapshot. If you have 200 stories and 5 modes and run on every commit, you'll burn through the free tier fast. Be strategic. Use --only-changed in development to snapshot only stories affected by changed files. Reserve full builds for PRs targeting main.
Story count also affects build time. At around 150-200 stories, a full Chromatic build can take 4-8 minutes. You can parallelize with Chromatic's --parallel flag, which spins up multiple workers to capture snapshots concurrently. For larger projects this cuts build time roughly in half.
One last thing: don't snapshot third-party components you don't own. If you're rendering a date picker from an external library inside your stories, its visual output will change whenever that library ships updates — none of which are regressions you introduced. Either mock external components or use the disableSnapshot parameter on those specific stories to exclude them from visual testing.
FAQ
Yes, currently Chromatic is built on top of Storybook. It publishes your Storybook build to Chromatic's cloud infrastructure and captures screenshots from there. There's no standalone Chromatic workflow without Storybook.
5,000 snapshots per month. Each story snapshot counts as one, and if you use modes (e.g. light + dark), each mode counts separately. 100 stories × 2 modes = 200 snapshots per build. You can comfortably evaluate Chromatic on the free tier for small-to-mid projects.
Both are visual regression services. Chromatic is tightly integrated with Storybook and handles story-level diffing with built-in review workflows. Percy is more framework-agnostic and works with Selenium, Cypress, or Playwright in addition to Storybook. For component-library-focused teams already using Storybook, Chromatic is typically the faster path.
Yes, using Chromatic's viewport modes feature. You define viewport sizes in your story parameters — say 375px for mobile and 1280px for desktop — and Chromatic captures a snapshot at each size. This catches layout shifts that only appear at specific breakpoints.
Use Chromatic's delay parameter to wait for animations to complete before capturing, or set pauseAnimationAtEnd: true in your story parameters. For timestamps or random IDs, mock them with deterministic values in your story's play function or args.
Yes. Tailwind v4.0.2 uses a CSS-first config approach instead of tailwind.config.js, but this doesn't affect Chromatic at all — Chromatic screenshots whatever CSS is rendered in the browser. As long as your Storybook preview correctly imports your compiled Tailwind stylesheet, the snapshots will reflect your actual styles.