Skip to main content

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.

Organized UI component blocks arranged in a grid system

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:

DimensionValues
Variantdefault, secondary, destructive, outline, ghost, link
Sizesm, md, lg
Statedefault, hover, focus, disabled, loading
Contenttext, icon, text+icon
Themelight, 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:

  1. Engineering review for code correctness
  2. Design review for visual correctness
  3. 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.