Storybook Visual Regression Testing with Chromatic and Playwright
Storybook gives you something valuable for visual testing: isolated components in controlled states. Every story is a reproducible, deterministic rendering of a component — no need to navigate a multi-step flow to get a button into its disabled state or a modal into its error state. That makes Storybook stories ideal test fixtures for visual regression testing.
This post covers two complementary approaches: Chromatic (the cloud service built for Storybook) and Playwright (for teams that want to run visual tests locally or in their own CI without a paid service).
Why Storybook + Visual Testing Works Well
Visual regression testing is most valuable when you can isolate exactly what changed. Full-page visual tests catch regressions but tell you "something on this page changed" — not which component broke, or in which state.
Stories give you component-level granularity. When a design token change causes a visual regression, a story-based test tells you precisely which component in which state is affected:
Button/Primary/Default✓Button/Primary/Hover✗ — background color shiftedButton/Secondary/Default✓
That precision makes triage fast and review manageable.
Setting Up Chromatic
Chromatic is designed for Storybook and requires almost no configuration. It discovers your stories automatically by building Storybook and uploading the build.
Install:
npm install --save-dev chromaticRun it:
npx chromatic --project-token=<your-token>First run establishes the baseline. Subsequent runs compare to it and report diffs.
For GitHub Actions:
# .github/workflows/chromatic.yml
name: Chromatic
on: push
jobs:
chromatic:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Needed for TurboSnap to compare with base branch
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Publish to Chromatic
uses: chromaui/action@latest
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
onlyChanged: true # TurboSnap: only screenshot stories that changed
exitZeroOnChanges: true # Don't fail CI — let the PR check block mergeonlyChanged: true enables TurboSnap, which uses your Webpack/Vite module graph and Git history to figure out which stories depend on files that changed, and only re-screenshots those. On a large component library this can reduce your snapshot count by 80%+.
Chromatic PR Workflow
When a run detects visual changes, Chromatic posts a failing status check to the pull request. The check links to Chromatic's review UI where you can see:
- Which stories changed
- A side-by-side diff with a slider to compare old and new
- Accept (this change was intentional) or Deny (this is a regression)
Once all diffs are reviewed and accepted (or denied with the PR blocked), the status check updates.
This workflow is the core value proposition: visual changes require explicit human sign-off before merge. It's not just a CI gate — it's a review process.
Configure who can approve changes in your Chromatic project settings. You can require specific reviewers (designers, leads) for visual approval separately from code reviewers.
Controlling What Chromatic Captures
By default, Chromatic screenshots every story. You can exclude stories or configure viewport/browser settings per story using story-level parameters:
// Button.stories.jsx
export default {
title: 'UI/Button',
component: Button,
};
export const Primary = {
args: { variant: 'primary', children: 'Click me' },
};
// Disable Chromatic for this story (it's too dynamic to snapshot)
export const WithAnimation = {
args: { animated: true },
parameters: {
chromatic: { disableSnapshot: true },
},
};
// Test at specific viewports
export const Responsive = {
args: { children: 'Responsive button' },
parameters: {
chromatic: { viewports: [375, 768, 1280] },
},
};
// Capture a story in a specific delay (wait for animation to settle)
export const WithTooltip = {
args: { showTooltip: true },
parameters: {
chromatic: { delay: 300 }, // ms before screenshot
},
};Using Playwright to Screenshot Storybook Stories
If you want to run visual tests without a cloud service — or want to run story-level visual tests in your existing Playwright suite — you can use Playwright directly against your Storybook dev server.
First, make sure Storybook is running. In CI, start it in the background:
- name: Start Storybook
run: npx storybook dev --port 6006 &
- name: Wait for Storybook
run: npx wait-on http://localhost:6006Then write Playwright tests that navigate to story URLs. Storybook's URL format for a story is:
http://localhost:6006/iframe.html?id=ui-button--primary&viewMode=storyThe id is the story's kebab-cased path: UI/Button/Primary → ui-button--primary.
// storybook-visual.spec.ts
import { test, expect } from '@playwright/test';
const STORYBOOK_URL = 'http://localhost:6006/iframe.html';
async function screenshotStory(page, storyId, name) {
await page.goto(`${STORYBOOK_URL}?id=${storyId}&viewMode=story`);
// Wait for the story to render
await page.waitForSelector('#storybook-root > *', { state: 'visible' });
// Give fonts and images time to load
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot(`${name}.png`, {
maxDiffPixelRatio: 0.01,
});
}
test('Button/Primary', async ({ page }) => {
await screenshotStory(page, 'ui-button--primary', 'button-primary');
});
test('Button/Secondary', async ({ page }) => {
await screenshotStory(page, 'ui-button--secondary', 'button-secondary');
});
test('Button/Disabled', async ({ page }) => {
await screenshotStory(page, 'ui-button--disabled', 'button-disabled');
});This approach generates Playwright snapshots that live in your repository alongside your test files. No cloud service, no cost per snapshot.
Programmatic Story Discovery with Storybook's API
Manually listing story IDs doesn't scale. Storybook exposes a stories.json endpoint that lists all stories:
http://localhost:6006/stories.jsonUse this to auto-generate visual tests for all stories:
// generate-story-tests.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Storybook visual regression', () => {
let stories: Array<{ id: string; name: string; title: string }> = [];
test.beforeAll(async ({ request }) => {
const response = await request.get('http://localhost:6006/stories.json');
const data = await response.json();
stories = Object.values(data.stories);
});
// This creates one test per story dynamically
// Note: Playwright requires static test definitions — use a fixture approach instead
});Since Playwright requires test definitions to be static, the pragmatic approach is to generate a test file from stories.json as a build step:
// scripts/generate-visual-tests.js
const fetch = require('node-fetch');
const fs = require('fs');
async function generate() {
const res = await fetch('http://localhost:6006/stories.json');
const { stories } = await res.json();
const tests = Object.values(stories).map(story => `
test('${story.title} / ${story.name}', async ({ page }) => {
await page.goto(\`http://localhost:6006/iframe.html?id=${story.id}&viewMode=story\`);
await page.waitForSelector('#storybook-root > *');
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('${story.id}.png', { maxDiffPixelRatio: 0.01 });
});
`).join('\n');
const content = `
import { test, expect } from '@playwright/test';
${tests}
`;
fs.writeFileSync('tests/visual/storybook.generated.spec.ts', content);
console.log(`Generated ${Object.keys(stories).length} visual tests`);
}
generate().catch(console.error);Commit the generated file. Regenerate it when stories are added or removed.
Stories as Fixtures for Other Tests
Beyond visual regression, stories serve as fixtures for interaction tests. Storybook 7+ has play functions — interactions that run inside the story — but Playwright can also interact with rendered stories:
test('Button click state', async ({ page }) => {
await page.goto('http://localhost:6006/iframe.html?id=ui-button--primary&viewMode=story');
await page.waitForSelector('#storybook-root > *');
// Take a before screenshot
await expect(page).toHaveScreenshot('button-default.png');
// Interact
await page.locator('button').hover();
// Take a hover state screenshot
await expect(page).toHaveScreenshot('button-hover.png');
await page.locator('button').click();
await expect(page).toHaveScreenshot('button-active.png');
});This gives you state-machine coverage of your component's visual states without needing to rebuild application context around it.
Combining Chromatic and Playwright
Many teams use both tools at different layers:
- Chromatic for automated component-level coverage that requires no test code beyond stories. Every story gets a baseline. PR changes require designer/QA sign-off via the review UI.
- Playwright for interaction sequences, full-page visual tests, and responsive breakpoint checks that require navigating real application flows.
The two tools complement each other. Chromatic catches "Button's background color changed in the Default story." Playwright catches "The checkout page layout breaks at 768px after three items are in the cart."
Handling Storybook Decorators in Visual Tests
Global Storybook decorators (themes, providers, wrappers) are automatically applied when Chromatic screenshots stories. When using Playwright against the iframe URL, they're also applied — the iframe renders the full story with all decorators.
If you need to test multiple themes, set the theme via story args or a Storybook globals switcher, and write separate tests for each:
const lightUrl = 'http://localhost:6006/iframe.html?id=ui-card--default&globals=theme:light';
const darkUrl = 'http://localhost:6006/iframe.html?id=ui-card--default&globals=theme:dark';
test('Card - light theme', async ({ page }) => {
await page.goto(lightUrl);
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('card-light.png');
});
test('Card - dark theme', async ({ page }) => {
await page.goto(darkUrl);
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('card-dark.png');
});Conclusion
Storybook makes visual regression testing tractable at the component level. Stories are deterministic by design — controlled state, no network dependencies, no user session required. Chromatic turns those stories into automatic visual baselines with a review workflow built for teams. Playwright gives you a code-first path to screenshot stories at any breakpoint or interaction state, without a cloud dependency.
Start with Chromatic if you want the fastest path to component coverage. Add Playwright story screenshots when you need finer control over interaction states, viewports, or want to keep visual tests self-hosted. The two complement each other and together give you coverage that unit tests structurally cannot provide.