Skip to main content

Command Palette

Search for a command to run...

Visual regression testing for design tokens and component libraries

Published
5 min readView as Markdown

Visual Regression Testing for Design Tokens and Component Libraries

Design systems live on the edge between precision and chaos. One typo in a color value, one rounding change in spacing, and every button in production looks wrong. Detecting these regressions before they ship is the whole point of visual testing.

The problem: Chromatic costs $500+/month. Percy is even more expensive. Rolling your own screenshot comparison is fragile (flaky baselines, OS rendering differences). And none of them integrate cleanly with your design token workflow.

Here's a pattern that works: screenshot every component variant after every design token change, diff against baseline, fail the build if the threshold is exceeded.

The setup

You have:

  • A Storybook with 50+ components
  • A design tokens system (style-dictionary, tokens-studio, or custom)
  • A CI/CD pipeline (GitHub Actions, GitLab CI, etc.)

Goal: Automatically screenshot all components → compare to baseline → alert on visual changes.

Step 1: List all Storybook stories

Storybook exposes story metadata via /index.json or /stories.json. Use this to build a list of all stories to screenshot:

// get-stories.js
const fetch = require('node-fetch');
const fs = require('fs');

async function getAllStories(baseUrl) {
  // Try Storybook 7+ first, fall back to 6
  let stories = [];

  try {
    const res = await fetch(`${baseUrl}/index.json`);
    const data = await res.json();
    stories = data.stories ? Object.values(data.stories) : [];
  } catch {
    const res = await fetch(`${baseUrl}/stories.json`);
    const data = await res.json();
    stories = data || [];
  }

  return stories.map(story => ({
    id: story.id || story.title,
    title: story.title,
    url: `${baseUrl}/iframe.html?id=${story.id || story.title}&viewMode=story`,
  }));
}

async function main() {
  const stories = await getAllStories('http://localhost:6006');
  console.log(`Found ${stories.length} stories`);
  fs.writeFileSync('stories.json', JSON.stringify(stories, null, 2));
}

main();

Run this after your Storybook builds:

npm run build-storybook
node get-stories.js

Step 2: Screenshot all stories with PageBolt

// screenshot-stories.js
const fetch = require('node-fetch');
const fs = require('fs');
const path = require('path');

const PAGEBOLT_API_KEY = process.env.PAGEBOLT_API_KEY;
const stories = JSON.parse(fs.readFileSync('stories.json', 'utf8'));

async function screenshotStory(story) {
  const res = await fetch('https://pagebolt.dev/api/v1/screenshot', {
    method: 'POST',
    headers: {
      'x-api-key': PAGEBOLT_API_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      url: story.url,
      selector: '#storybook-root', // Crop to just the component
      blockBanners: true,
      width: 1280,
      height: 720,
    }),
    timeout: 30000,
  });

  if (!res.ok) {
    console.error(`Failed to screenshot ${story.id}: ${res.status}`);
    return null;
  }

  const buffer = await res.buffer();
  const dir = path.join('screenshots', 'current');
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });

  const filename = `${story.id.replace(/\//g, '__')}.png`;
  fs.writeFileSync(path.join(dir, filename), buffer);
  console.log(`✓ ${story.id}`);
  return filename;
}

async function main() {
  console.log(`Screenshotting ${stories.length} stories...`);

  // Limit concurrency to avoid rate limits
  const concurrency = 3;
  for (let i = 0; i < stories.length; i += concurrency) {
    const batch = stories.slice(i, i + concurrency);
    await Promise.all(batch.map(screenshotStory));
    // Stagger batches
    if (i + concurrency < stories.length) {
      await new Promise(r => setTimeout(r, 1000));
    }
  }
}

main();

Step 3: Compare to baseline

Use pixelmatch or sharp to diff new screenshots against baseline:

// compare-screenshots.js
const fs = require('fs');
const path = require('path');
const PNG = require('pngjs').PNG;
const pixelmatch = require('pixelmatch');

const THRESHOLD = 0.01; // 1% of total pixels allowed to differ (our tolerance threshold)

function compareScreenshots() {
  const currentDir = 'screenshots/current';
  const baselineDir = 'screenshots/baseline';
  const reportDir = 'screenshots/diffs';

  if (!fs.existsSync(baselineDir)) {
    console.log('Baseline directory not found. Copying current as baseline.');
    fs.cpSync(currentDir, baselineDir, { recursive: true });
    return true; // Pass on first run
  }

  if (!fs.existsSync(reportDir)) fs.mkdirSync(reportDir, { recursive: true });

  const currentFiles = fs.readdirSync(currentDir);
  let failed = [];

  currentFiles.forEach(filename => {
    const currentPath = path.join(currentDir, filename);
    const baselinePath = path.join(baselineDir, filename);

    if (!fs.existsSync(baselinePath)) {
      console.log(`[NEW] ${filename}`);
      return;
    }

    const current = PNG.sync.read(fs.readFileSync(currentPath));
    const baseline = PNG.sync.read(fs.readFileSync(baselinePath));

    const diff = new PNG({ width: current.width, height: current.height });
    // pixelmatch threshold: 0.1 = per-pixel color sensitivity (0.1 = 10% RGB tolerance)
    const pixelsChanged = pixelmatch(
      current.data,
      baseline.data,
      diff.data,
      current.width,
      current.height,
      { threshold: 0.1 }
    );

    const totalPixels = current.width * current.height;
    const percentDiff = (pixelsChanged / totalPixels) * 100;

    if (percentDiff > THRESHOLD) {
      console.log(`[FAIL] ${filename}: ${percentDiff.toFixed(2)}% changed`);
      fs.writeFileSync(path.join(reportDir, filename), PNG.sync.write(diff));
      failed.push(filename);
    } else {
      console.log(`[PASS] ${filename}`);
    }
  });

  return failed.length === 0;
}

if (!compareScreenshots()) {
  console.log('\nVisual regressions detected.');
  process.exit(1);
}

Step 4: GitHub Actions workflow

name: Design System Visual Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  visual-regression:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: npm ci

      - name: Build Storybook
        run: npm run build-storybook

      - name: Get all stories
        run: node get-stories.js

      - name: Screenshot all components
        env:
          PAGEBOLT_API_KEY: ${{ secrets.PAGEBOLT_API_KEY }}
        run: node screenshot-stories.js

      - name: Compare against baseline
        run: node compare-screenshots.js

      - name: Upload diff report (on failure)
        if: failure()
        uses: actions/upload-artifact@v3
        with:
          name: visual-diffs
          path: screenshots/diffs/

      - name: Update baseline (optional)
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        run: cp -r screenshots/current screenshots/baseline

Why this works for design systems

  1. Token changes are caught immediately — Update a color value, run the build, see which components are affected
  2. No flakiness — hosted browser = consistent rendering every time
  3. Cheap — PageBolt free tier covers most design systems; $29/mo for larger systems
  4. Git-friendly — baseline images live in your repo; diffs are artifacts, not bloat
  5. Collaborate easily — artifacts show the exact pixels that changed

Real-world scenario

Your design lead changes the primary button color from #007AFF to #0056B3 (darker blue). You run the build. Visual regression catches:

  • 47 button variants changed ✓
  • Alert component text color shifted ✓
  • Card header styling affected ✓

All flagged before the PR is merged. The diff artifacts show exactly what changed.


Try it free — 100 requests/month, no credit card. → Get started in 2 minutes

More from this blog

P

Pagebolt.dev

401 posts