Visual regression testing for design tokens and component libraries
Automatically screenshot every component variant in your design system and detect visual regressions from design token changes — without installing a browser.
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
Get Started Free
100 requests/month, no credit card
Screenshots, PDFs, video recording, and browser automation — no headless browser to manage.
Get Your Free API Key →