DEV Community

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

Visual regression testing for design tokens and component libraries

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();
Enter fullscreen mode Exit fullscreen mode

Run this after your Storybook builds:

npm run build-storybook
node get-stories.js
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)