Chromatic + Storybook: Visual Testing for UI Components
Chromatic is a visual testing tool built specifically for Storybook. While Percy and Applitools operate at the page level (snapshot a URL), Chromatic operates at the component level — it captures every story in your Storybook and compares them against approved baselines.
This makes Chromatic the right choice for design system teams and component libraries. If your UI is built in React, Vue, Angular, or Svelte with Storybook, Chromatic slots in without changing your existing test architecture.
How Chromatic Works
- You push a commit
- GitHub Actions runs
chromatic --project-token=... - Chromatic publishes your Storybook to their cloud
- Chromatic renders every story across multiple browsers
- Changed stories are flagged for review in the Chromatic UI
- Your team approves or rejects the visual changes
- The GitHub status check passes or fails based on review outcome
The critical difference from page-level tools: Chromatic captures each story as an isolated component test, not a full application page. You test the Button component in all its variants, not the checkout page that happens to contain a button.
Setup
Install Chromatic
npm install --save-dev chromaticAdd a project
Sign up at chromatic.com, create a project, and get your project token. The token is not a secret — it identifies your project but doesn't grant write access.
Run Chromatic for the first time
npx chromatic --project-token=YOUR_TOKENThe first run creates baselines for all stories. Subsequent runs compare against these baselines.
Writing Stories for Visual Testing
Chromatic tests every story you write. Good stories are the foundation of useful visual tests.
Component with variants
// src/components/Button/Button.stories.jsx
import { Button } from './Button';
export default {
title: 'Components/Button',
component: Button,
parameters: {
chromatic: { viewports: [375, 1280] }
}
};
// Each named export is one story
export const Primary = {
args: {
variant: 'primary',
children: 'Click me',
size: 'medium'
}
};
export const Secondary = {
args: {
variant: 'secondary',
children: 'Cancel',
size: 'medium'
}
};
export const Destructive = {
args: {
variant: 'destructive',
children: 'Delete account',
size: 'medium'
}
};
export const Loading = {
args: {
variant: 'primary',
children: 'Saving...',
loading: true,
disabled: true
}
};
export const Sizes = {
render: () => (
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<Button size="small">Small</Button>
<Button size="medium">Medium</Button>
<Button size="large">Large</Button>
</div>
)
};Stories that capture interaction states
Use Storybook's play function to interact with components before the visual snapshot is taken:
import { userEvent, within } from '@storybook/testing-library';
export const WithValidationErrors = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Fill in invalid data
await userEvent.type(
canvas.getByLabelText('Email'),
'not-an-email'
);
// Submit to trigger validation
await userEvent.click(canvas.getByRole('button', { name: 'Submit' }));
}
};
export const Focused = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
canvas.getByRole('button').focus();
}
};
export const MenuOpen = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: 'Open menu' }));
await canvas.findByRole('menu'); // wait for menu to appear
}
};Chromatic waits for the play function to complete before capturing the snapshot. This lets you test hover states, open menus, validation errors, and other interaction-triggered states.
Controlling Chromatic per-story
export const AnimatedComponent = {
args: { /* ... */ },
parameters: {
chromatic: {
// Pause animations at a specific point
pauseAnimationAtEnd: true,
// Override viewport for this story only
viewports: [320, 1440],
// Delay snapshot by 300ms (for animations)
delay: 300,
// Skip this story in Chromatic
disableSnapshot: true
}
}
};Disabling snapshots for documentation-only stories
Some stories are just documentation, not testable states:
export const Usage = {
render: () => <div>This story shows usage examples</div>,
parameters: {
chromatic: { disableSnapshot: true }
}
};TurboSnap: Only Test Changed Components
TurboSnap is Chromatic's smart snapshot system. Instead of re-snapping every story on every commit, it analyzes your git diff and only snapshots stories whose dependencies changed.
npx chromatic --project-token=YOUR_TOKEN --only-changedFor a project with 500 stories, this can reduce snapshot count from 500 to 15-20 on a typical feature branch — dramatically reducing build time and cost.
TurboSnap is reliable for component changes but falls back to full snapshots when:
- Configuration files change (
chromatic.config.js,.storybook/main.js) - Global decorators change
- Chromatic detects it can't confidently trace the dependency tree
Interaction Tests with @storybook/test
Combine visual tests with functional assertions in the same story:
import { expect } from '@storybook/test';
import { userEvent, within } from '@storybook/testing-library';
export const LoginFormSuccess = {
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
await step('Fill in credentials', async () => {
await userEvent.type(canvas.getByLabelText('Email'), 'alice@example.com');
await userEvent.type(canvas.getByLabelText('Password'), 'secret123');
});
await step('Submit form', async () => {
await userEvent.click(canvas.getByRole('button', { name: 'Log in' }));
});
await step('Verify success state', async () => {
await expect(canvas.getByText('Welcome back, Alice!')).toBeVisible();
});
}
};Chromatic captures the final state after all interactions. The step labels appear in the Storybook interactions panel, making it easy to trace what happened.
The Review Workflow
When Chromatic detects visual changes, it creates a review build:
- Storybook published — your Storybook is accessible at
chromatic.com/builds/... - Changed stories listed — every story with a diff is flagged
- Side-by-side comparison — baseline vs current, with diff overlay
- Team review — anyone with project access can approve or deny
- CI status — the GitHub check passes when all changes are reviewed
Accepting changes
In the Chromatic review UI:
- Click the checkmark to accept a change (it becomes the new baseline)
- Click X to deny (the CI check fails)
- Use "Accept all" for bulk acceptance when you've made an intentional design update
- Leave comments on specific changes to document why something was approved
Squash merges and baselines
If your team uses squash merges, configure Chromatic to track main as the baseline branch:
npx chromatic --project-token=YOUR_TOKEN --auto-accept-changes mainThis automatically accepts visual changes on the main branch (since they've already been reviewed in PR).
GitHub Actions Integration
name: Chromatic
on:
push:
branches-ignore:
- main # main uses auto-accept, not PR review
jobs:
chromatic:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # TurboSnap needs full git history
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci
- name: Run Chromatic
uses: chromaui/action@latest
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
onlyChanged: true # enable TurboSnap
exitZeroOnChanges: false # fail PR on unreviewed changesFor main branch (post-merge):
- name: Run Chromatic (main)
uses: chromaui/action@latest
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
autoAcceptChanges: true # auto-accept on main after PR reviewStorybook Composition
If you have multiple component libraries (a design system + application components), use Storybook Composition to test them all through one Chromatic project:
// .storybook/main.js
module.exports = {
refs: {
'design-system': {
title: 'Design System',
url: 'https://your-design-system.chromatic.com'
}
}
};What Chromatic Catches
Chromatic catches:
- Component visual regressions from CSS changes
- Layout shifts when a dependency changes
- Unexpected side effects from global CSS changes (e.g. changing
bodyfont-size breaks every component) - Cross-browser rendering differences
- Responsive layout changes across viewports
Chromatic doesn't catch:
- Application-level regressions (full page, multi-component flows)
- Functional bugs in business logic
- API integration issues
- Performance regressions
For application-level visual testing, combine Chromatic (component level) with Percy or Applitools (page level).
Chromatic vs Percy vs Applitools
| Tool | Best for | Snapshot level |
|---|---|---|
| Chromatic | Storybook component libraries | Component/story |
| Percy | Multi-page web apps | Page |
| Applitools | Enterprise, cross-browser, AI diff | Component and page |
Most teams with a Storybook use Chromatic. Teams without Storybook use Percy or Applitools for page-level visual testing.
Getting Started Checklist
- Write stories for every component state (default, hover, error, loading, empty)
- Add
playfunctions for interaction-triggered states (open menus, validation errors) - Configure
viewportsinparameters.chromaticfor responsive components - Set
disableSnapshot: truefor documentation-only stories - Add Chromatic to your CI pipeline with
--only-changed(TurboSnap) - Define your review workflow: who approves, when to auto-accept
- Set up Slack notifications for new review builds
Chromatic is the most efficient visual testing tool for component library teams. If you use Storybook and you're not using Chromatic, you're leaving a significant testing layer uncovered.