Storybook Visual Regression Testing with Chromatic and Playwright

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 shifted
  • Button/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 chromatic

Run 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 merge

onlyChanged: 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:

  1. Which stories changed
  2. A side-by-side diff with a slider to compare old and new
  3. 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:6006

Then 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=story

The 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.json

Use 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.

Read more

Start now free