The Problem
A design system with 200+ React components powers 4 product teams' UIs. CSS changes cause cascading visual bugs: updating a button's padding breaks card layouts, changing a color token makes text unreadable on dark backgrounds, and a z-index change hides dropdown menus. These bugs reach production 2-3 times per month because unit tests pass — the logic is correct, but the UI looks broken. Each visual bug takes 2-3 hours to find, fix, and deploy.
Step 1: Storybook Screenshot Capture
typescript
// src/visual-tests/capture.ts
import { chromium, type Browser, type Page } from 'playwright';
import { readdir } from 'fs/promises';
import { join } from 'path';
interface CaptureConfig {
storybookUrl: string;
outputDir: string;
viewports: Array<{ name: string; width: number; height: number }>;
themes: string[];
}
const defaultConfig: CaptureConfig = {
storybookUrl: 'http://localhost:6006',
outputDir: '.visual-tests/current',
viewports: [
{ name: 'mobile', width: 375, height: 812 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'desktop', width: 1440, height: 900 },
],
themes: ['light', 'dark'],
};
export async function captureAllStories(config = defaultConfig): Promise<string[]> {
const browser = await chromium.launch();
const screenshots: string[] = [];
try {
// Fetch story list from Storybook API
const page = await browser.newPage();
await page.goto(`${config.storybookUrl}/index.json`);
const storyIndex = await page.evaluate(() => document.body.textContent);
const stories = JSON.parse(storyIndex!);
for (const [id, story] of Object.entries(stories.entries as Record<string, any>)) {
if (story.type !== 'story') continue;
for (const viewport of config.viewports) {
for (const theme of config.themes) {
const filename = `${id}--${viewport.name}--${theme}.png`;
const filepath = join(config.outputDir, filename);
await page.setViewportSize({ width: viewport.width, height: viewport.height });
// Navigate to story in iframe mode with theme
const url = `${config.storybookUrl}/iframe.html?id=${id}&globals=theme:${theme}`;
await page.goto(url, { waitUntil: 'networkidle' });
// Wait for animations to settle
await page.waitForTimeout(500);
await page.screenshot({ path: filepath, fullPage: true });
screenshots.push(filepath);
}
}
}
} finally {
await browser.close();
}
return screenshots;
}
Step 2: Pixel-Level Diff Engine
typescript
// src/visual-tests/diff.ts
import { readFile, writeFile, mkdir } from 'fs/promises';
import { join, basename } from 'path';
import { PNG } from 'pngjs';
import pixelmatch from 'pixelmatch';
interface DiffResult {
filename: string;
status: 'match' | 'changed' | 'new' | 'removed';
diffPercent: number;
diffPixels: number;
diffImagePath?: string;
}
export async function compareScreenshots(
baselineDir: string,
currentDir: string,
diffDir: string,
threshold: number = 0.1 // percent difference to flag
): Promise<DiffResult[]> {
await mkdir(diffDir, { recursive: true });
const results: DiffResult[] = [];
const currentFiles = await readdir(currentDir);
for (const filename of currentFiles) {
if (!filename.endsWith('.png')) continue;
const currentPath = join(currentDir, filename);
const baselinePath = join(baselineDir, filename);
try {
const baselineData = await readFile(baselinePath);
const currentData = await readFile(currentPath);
const baseline = PNG.sync.read(baselineData);
const current = PNG.sync.read(currentData);
// Handle size changes
if (baseline.width !== current.width || baseline.height !== current.height) {
results.push({
filename, status: 'changed', diffPercent: 100, diffPixels: -1,
diffImagePath: currentPath,
});
continue;
}
const diff = new PNG({ width: baseline.width, height: baseline.height });
const diffPixels = pixelmatch(
baseline.data, current.data, diff.data,
baseline.width, baseline.height,
{ threshold: 0.1 } // per-pixel sensitivity
);
const totalPixels = baseline.width * baseline.height;
const diffPercent = (diffPixels / totalPixels) * 100;
if (diffPercent > threshold) {
const diffPath = join(diffDir, `diff-${filename}`);
await writeFile(diffPath, PNG.sync.write(diff));
results.push({
filename, status: 'changed', diffPercent, diffPixels,
diffImagePath: diffPath,
});
} else {
results.push({ filename, status: 'match', diffPercent, diffPixels });
}
} catch {
// No baseline = new component
results.push({ filename, status: 'new', diffPercent: 100, diffPixels: -1 });
}
}
return results;
}
Step 3: GitHub Actions Integration
yaml
# .github/workflows/visual-test.yml
name: Visual Regression Tests
on:
pull_request:
paths: ['src/components/**', 'src/styles/**', 'src/tokens/**']
jobs:
visual-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci
- name: Build Storybook
run: npx storybook build -o storybook-static
- name: Start Storybook
run: npx http-server storybook-static -p 6006 &
- name: Wait for Storybook
run: npx wait-on http://localhost:6006
- name: Download baselines
uses: actions/download-artifact@v4
with:
name: visual-baselines
path: .visual-tests/baseline
continue-on-error: true
- name: Capture screenshots
run: npx tsx src/visual-tests/capture.ts
- name: Compare screenshots
id: diff
run: npx tsx src/visual-tests/diff.ts > diff-report.json
- name: Post PR comment with diffs
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const results = JSON.parse(fs.readFileSync('diff-report.json'));
const changed = results.filter(r => r.status === 'changed');
if (changed.length === 0) {
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: context.issue.number,
body: '✅ **Visual Tests Passed** — No visual changes detected.',
});
return;
}
let body = `## 🖼️ Visual Changes Detected (${changed.length})\n\n`;
for (const c of changed.slice(0, 20)) {
body += `- **${c.filename}**: ${c.diffPercent.toFixed(2)}% changed (${c.diffPixels} pixels)\n`;
}
body += '\n> Review the diff images in the artifacts to approve or fix these changes.';
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: context.issue.number, body,
});
- name: Fail on unexpected changes
run: |
CHANGED=$(cat diff-report.json | node -e "
const d=[];process.stdin.on('data',c=>d.push(c));
process.stdin.on('end',()=>{
const r=JSON.parse(d.join(''));
console.log(r.filter(x=>x.status==='changed').length)
})")
if [ "$CHANGED" -gt "0" ]; then
echo "❌ ${CHANGED} visual changes need review"
exit 1
fi
- name: Upload baselines
if: github.ref == 'refs/heads/main'
uses: actions/upload-artifact@v4
with:
name: visual-baselines
path: .visual-tests/current
retention-days: 90
Results
- Visual bugs in production: dropped from 2-3/month to zero
- 200+ components: every variant screenshotted across 3 viewports × 2 themes = 1,200+ screenshots
- Unintended CSS changes: caught in PR review with pixel-diff images
- Design token changes: immediately see impact across all components
- Review confidence: designers approve PRs with visual proof, not guesswork
- CI time: 4 minutes for full visual regression suite
- Time saved: 6-9 hours/month previously spent finding and fixing visual bugs