Visual Testing Your Design System: A Component-by-Component Approach
Design systems promise consistency, but without visual testing you are just hoping your 200+ components render correctly everywhere. Here is a systematic approach.
The Design System Testing Gap
Your design system has a Storybook with 200+ component stories. Every component has unit tests for its props and behavior. But does anyone actually verify that the Button component with variant="destructive" and size="sm" renders with the correct red background and 32px height?
This is the gap visual testing fills. It validates the visual output of every component, in every variant, at every breakpoint.
The Testing Matrix
For a typical design system button component, the testing matrix looks like this:
| Dimension | Values |
|---|---|
| Variant | default, secondary, destructive, outline, ghost, link |
| Size | sm, md, lg |
| State | default, hover, focus, disabled, loading |
| Content | text, icon, text+icon |
| Theme | light, dark |
That is 6 x 3 x 5 x 3 x 2 = 540 visual states for a single component. Multiply by the dozens of components in your system, and you quickly understand why manual visual QA is impossible.
Automating With Storybook
Storybook is the natural integration point for design system visual testing. Each story represents a visual state:
// Button.stories.tsx
export const AllVariants: Story = {
render: () => (
<div className="flex flex-col gap-4">
{(['default', 'secondary', 'destructive', 'outline'] as const).map(
(variant) => (
<div key={variant} className="flex items-center gap-2">
<Button variant={variant} size="sm">Small</Button>
<Button variant={variant} size="default">Default</Button>
<Button variant={variant} size="lg">Large</Button>
</div>
)
)}
</div>
),
}
Then use a visual testing tool to capture and compare screenshots of every story on every PR.
Handling Theme Variants
Design systems typically support light and dark themes. Your visual tests need to cover both:
const themes = ['light', 'dark'] as const
for (const theme of themes) {
test(`button renders in ${theme} mode`, async ({ page }) => {
await page.goto(`/iframe.html?id=button--all-variants`)
await page.evaluate((t) => {
document.documentElement.classList.toggle('dark', t === 'dark')
}, theme)
await expect(page).toHaveScreenshot(`button-${theme}.png`)
})
}
Token Validation
Beyond visual comparison, you can validate that components use the correct design tokens:
test('button uses correct design tokens', async ({ page }) => {
await page.goto('/iframe.html?id=button--default')
const button = page.locator('button')
const styles = await button.evaluate((el) => {
const computed = getComputedStyle(el)
return {
backgroundColor: computed.backgroundColor,
borderRadius: computed.borderRadius,
fontWeight: computed.fontWeight,
padding: computed.padding,
}
})
expect(styles.borderRadius).toBe('8px')
expect(styles.fontWeight).toBe('500')
})
This catches CSS drift at the token level, not just the visual level.
Responsive Component Testing
Components need to work at every viewport size. For a card component that switches from horizontal to vertical layout at mobile:
test('card switches layout at mobile', async ({ page }) => {
// Desktop - horizontal layout
await page.setViewportSize({ width: 1024, height: 768 })
await page.goto('/iframe.html?id=card--responsive')
await expect(page.locator('.card')).toHaveScreenshot('card-desktop.png')
// Mobile - vertical layout
await page.setViewportSize({ width: 375, height: 812 })
await expect(page.locator('.card')).toHaveScreenshot('card-mobile.png')
})
The Approval Workflow
When visual tests detect changes, someone needs to decide whether the change is intentional. For design systems, this workflow should involve:
- Engineering review for code correctness
- Design review for visual correctness
- Automated checks for accessibility (contrast ratios, text sizes)
Tools like Chromatic integrate this workflow directly into your PR process, showing side-by-side comparisons with one-click approval.
Measuring Coverage
Track which components have visual tests and which do not:
# Compare stories with visual test coverage
total_stories=$(find src -name "*.stories.tsx" | wc -l)
tested_stories=$(grep -rl "toHaveScreenshot" tests/ | wc -l)
echo "Visual test coverage: $tested_stories/$total_stories"
Aim for 100% coverage of your core components (buttons, inputs, cards, navigation) and at least 80% coverage of composite components.
Continue with ScanU
If you want to apply these techniques in production, start with a focused set of pages and run baseline screenshot comparison after every meaningful UI change. You can review plans on Pricing, implementation details in the FAQ, and product capabilities on Features.