Visual Testing for Design Systems: Tokens, Components, and Themes
Design systems are infrastructure. When they break, they break everywhere — every product built on them, every component consuming their tokens. A subtle color shift in your primary token, an unintended spacing change, a font-weight regression: these propagate silently until someone notices in production.
Visual regression testing is the safety net that catches these regressions before they ship. This guide covers the specific patterns that make visual testing effective for design systems: token change testing, component visual contracts, theme switching, and responsive breakpoint coverage.
Why Design Systems Need Visual Testing
Unit tests and TypeScript types guard your design system's API — they verify that <Button variant="primary"> renders without throwing, that props are correctly typed, that tokens export the expected values.
But they can't tell you whether the button looks right. Whether the border-radius changed from 4px to 6px. Whether the disabled state's opacity is 0.4 or 0.5. Whether the hover state's background color shifted by 10% in luminosity.
Visual regression tests capture the rendered output and compare it pixel-by-pixel against a known-good baseline. They turn "does it look right?" from a manual check into an automated assertion.
Testing Design Token Changes
Design tokens are the foundation. A change to a token — intentional or not — can affect hundreds of components. Visual tests at the token level let you see the cascade before it merges.
Token Inventory Snapshots
Create a dedicated "token showcase" story or page that renders swatches for every token category. Screenshot this page. When tokens change, this snapshot flags it immediately:
// tokens.stories.jsx
export default { title: 'Design Tokens' };
export const Colors = () => (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px' }}>
{Object.entries(tokens.color).map(([name, value]) => (
<div key={name}>
<div style={{ background: value, width: 80, height: 80, borderRadius: 4 }} />
<code>{name}</code>
<code>{value}</code>
</div>
))}
</div>
);
export const Typography = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{Object.entries(tokens.typography.scale).map(([name, size]) => (
<p key={name} style={{ fontSize: size, margin: 0 }}>
{name}: The quick brown fox
</p>
))}
</div>
);
export const Spacing = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{Object.entries(tokens.spacing).map(([name, value]) => (
<div key={name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: value, height: 24, background: '#0066cc' }} />
<code>{name}: {value}</code>
</div>
))}
</div>
);Screenshot these with Chromatic or Playwright. When a designer changes color.primary.500 from #0066cc to #0052a3, the Colors snapshot diff shows exactly what changed.
Token Change Impact Testing
To surface the blast radius of a token change, organize your visual tests by which tokens each component uses:
// Playwright test that checks token-consuming components
import { test, expect } from '@playwright/test';
const tokenConsumers = {
'color.primary': [
{ story: 'ui-button--primary', name: 'button-primary' },
{ story: 'ui-link--default', name: 'link-default' },
{ story: 'ui-badge--info', name: 'badge-info' },
],
'spacing.md': [
{ story: 'ui-card--default', name: 'card-default' },
{ story: 'ui-list-item--default', name: 'list-item-default' },
],
};
for (const [token, consumers] of Object.entries(tokenConsumers)) {
test.describe(`Token: ${token}`, () => {
for (const { story, name } of consumers) {
test(name, async ({ page }) => {
await page.goto(`http://localhost:6006/iframe.html?id=${story}&viewMode=story`);
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot(`${name}.png`, { maxDiffPixelRatio: 0.005 });
});
}
});
}When tests fail after a token change, the test names tell you exactly which token → which components were affected.
Component Visual Contract Testing
A visual contract test formalizes the visual specification of a component: "In this state, with these props, the component must look like this." It's the visual equivalent of a snapshot test, but for the rendered output rather than the DOM structure.
Write one test per significant component state:
// button.visual.spec.ts
import { test, expect } from '@playwright/test';
const BASE = 'http://localhost:6006/iframe.html';
test.describe('Button visual contracts', () => {
const states = [
{ id: 'ui-button--primary', name: 'primary-default' },
{ id: 'ui-button--primary-hover', name: 'primary-hover' },
{ id: 'ui-button--primary-focused', name: 'primary-focused' },
{ id: 'ui-button--primary-disabled', name: 'primary-disabled' },
{ id: 'ui-button--primary-loading', name: 'primary-loading' },
{ id: 'ui-button--secondary', name: 'secondary-default' },
{ id: 'ui-button--destructive', name: 'destructive-default' },
{ id: 'ui-button--ghost', name: 'ghost-default' },
// Sizes
{ id: 'ui-button--size-sm', name: 'size-sm' },
{ id: 'ui-button--size-md', name: 'size-md' },
{ id: 'ui-button--size-lg', name: 'size-lg' },
];
for (const { id, name } of states) {
test(name, async ({ page }) => {
await page.goto(`${BASE}?id=${id}&viewMode=story`);
await page.waitForSelector('#storybook-root > *');
await page.waitForLoadState('networkidle');
const root = page.locator('#storybook-root');
await expect(root).toHaveScreenshot(`button-${name}.png`, {
maxDiffPixelRatio: 0.005,
});
});
}
});Screenshot the component root rather than the full page — this gives you a tight crop around the component and makes diffs easier to interpret.
Capturing Interaction States
Focus, hover, and active states require special handling since they depend on user interaction:
test('Button hover state', async ({ page }) => {
await page.goto(`${BASE}?id=ui-button--primary&viewMode=story`);
await page.waitForLoadState('networkidle');
const button = page.locator('#storybook-root button');
// Baseline
await expect(button).toHaveScreenshot('button-default.png');
// Hover
await button.hover();
await expect(button).toHaveScreenshot('button-hover.png');
// Focus via keyboard
await page.keyboard.press('Tab');
await expect(button).toHaveScreenshot('button-focus.png');
});For states that are hard to trigger (:active requires holding the mouse down), use CSS class injection or a story that applies the pseudo-class directly:
// In your story
export const PseudoStates = () => (
<div>
<Button className="button--hover">Hover State</Button>
<Button className="button--focus">Focus State</Button>
<Button className="button--active">Active State</Button>
</div>
);Dark Mode and Theme Switching
Testing themes is one of the highest-value uses of visual regression testing. Theme bugs — a component that uses hardcoded colors instead of tokens, a border that's invisible in dark mode — are exactly what visual tests catch.
Storybook Globals for Themes
Configure Storybook to expose a theme switcher via globals:
// .storybook/preview.js
export const globalTypes = {
theme: {
name: 'Theme',
defaultValue: 'light',
toolbar: {
items: ['light', 'dark', 'high-contrast'],
},
},
};
export const decorators = [
(Story, context) => {
const theme = context.globals.theme;
return (
<ThemeProvider theme={themes[theme]}>
<Story />
</ThemeProvider>
);
},
];Visual Tests for Each Theme
const themes = ['light', 'dark', 'high-contrast'];
const components = [
{ id: 'ui-button--primary', name: 'button-primary' },
{ id: 'ui-card--default', name: 'card-default' },
{ id: 'ui-input--default', name: 'input-default' },
{ id: 'ui-modal--open', name: 'modal-open' },
];
for (const theme of themes) {
test.describe(`Theme: ${theme}`, () => {
for (const { id, name } of components) {
test(name, async ({ page }) => {
const url = `http://localhost:6006/iframe.html?id=${id}&viewMode=story&globals=theme:${theme}`;
await page.goto(url);
await page.waitForLoadState('networkidle');
await expect(page.locator('#storybook-root')).toHaveScreenshot(
`${name}-${theme}.png`,
{ maxDiffPixelRatio: 0.005 }
);
});
}
});
}This generates a matrix of component × theme tests. When dark mode breaks a component, the test name tells you exactly which component and theme failed.
Chromatic Theme Matrix
In Chromatic, you can configure stories to be captured in multiple theme globals automatically:
// component.stories.jsx
export default {
title: 'UI/Card',
parameters: {
chromatic: {
modes: {
light: { globals: { theme: 'light' } },
dark: { globals: { theme: 'dark' } },
},
},
},
};This tells Chromatic to screenshot each story twice — once per mode — without writing separate test cases.
Responsive Breakpoint Testing
Design systems define breakpoints. Components should look correct at each. Visual tests at multiple viewports catch layout regressions that only appear at specific widths.
Define Your Breakpoints
// test-utils/breakpoints.ts
export const breakpoints = {
mobile: { width: 375, height: 812 },
mobileLg: { width: 428, height: 926 },
tablet: { width: 768, height: 1024 },
desktop: { width: 1280, height: 800 },
desktopLg: { width: 1440, height: 900 },
} as const;Responsive Component Tests
import { test, expect } from '@playwright/test';
import { breakpoints } from '../test-utils/breakpoints';
test.describe('Navigation component - responsive', () => {
for (const [bpName, viewport] of Object.entries(breakpoints)) {
test(`renders at ${bpName} (${viewport.width}px)`, async ({ page }) => {
await page.setViewportSize(viewport);
await page.goto('http://localhost:6006/iframe.html?id=layout-navigation--default&viewMode=story');
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot(`nav-${bpName}.png`, {
maxDiffPixelRatio: 0.01,
});
});
}
});Grid and Layout Tests
For layout components (Grid, Stack, Flex), test at multiple widths to catch reflow bugs:
test.describe('Grid system', () => {
const scenarios = [
{ story: 'layout-grid--two-col', name: 'two-col' },
{ story: 'layout-grid--three-col', name: 'three-col' },
{ story: 'layout-grid--auto-fit', name: 'auto-fit' },
];
const viewports = [375, 768, 1280];
for (const { story, name } of scenarios) {
for (const width of viewports) {
test(`${name} at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.goto(`http://localhost:6006/iframe.html?id=${story}&viewMode=story`);
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot(`grid-${name}-${width}.png`);
});
}
}
});Maintaining the Test Suite as the Design System Evolves
Visual tests in a design system need disciplined maintenance. Some patterns that help:
Tag tests by token dependency. When a token changes, you know which tests to update:
test('Button primary [token:color.primary]', async ({ page }) => { ... });Separate intentional updates from regressions. When updating baselines, always review the diff. Automate baseline updates only in a dedicated workflow triggered manually or by a protected branch — never auto-update on every PR.
Keep snapshot count manageable. A matrix of 50 components × 3 themes × 5 breakpoints = 750 snapshots. That's a lot to review when a shared token changes. Prioritize: snapshot the components that are highest-risk (most consumed, most complex states) rather than every story at every breakpoint.
Run visual tests in a separate CI job. Visual test failures are different from unit test failures — they require human review, not automated fixes. Keep them in a separate job so a visual change doesn't block your entire pipeline.
jobs:
unit-tests:
runs-on: ubuntu-latest
steps: [...]
visual-tests:
runs-on: ubuntu-latest
needs: unit-tests
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx playwright install chromium --with-deps
- run: npm run storybook:build
- run: npx serve storybook-static -p 6006 &
- run: npx wait-on http://localhost:6006
- run: npx playwright test --config playwright.visual.config.ts
- uses: actions/upload-artifact@v4
if: failure()
with:
name: visual-report
path: playwright-report/Conclusion
Visual regression testing is the only automated way to verify that your design system's output is what you intended. Token showcase snapshots catch the cascade effect of a shared value changing. Component visual contracts catch state-level regressions. Theme matrix tests catch dark mode and accessibility theme bugs. Responsive tests catch layout regressions at specific breakpoints.
The investment is front-loaded (writing the tests, establishing baselines, integrating into CI) but the ongoing cost is low: update baselines when you intentionally change something, and treat every unexpected diff as a bug to investigate. That's the correct definition of a regression test.