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
- Token changes are caught immediately — Update a color value, run the build, see which components are affected
- No flakiness — hosted browser = consistent rendering every time
- Cheap — PageBolt free tier covers most design systems; $29/mo for larger systems
- Git-friendly — baseline images live in your repo; diffs are artifacts, not bloat
- 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 (1)
Extracting the component tree via Storybook's JSON is the smartest way to dodge those steep Chromatic pricing tiers. The main headache with custom visual regression pipelines is text anti-aliasing throwing false positives across different CI runners. Running your baseline generation and PR checks inside an identical Docker image perfectly isolates the environment. It keeps pixel diffs strict enough to catch a rogue token update without failing the build over minor font smoothing quirks.