Snapshotting Component Screenshots on Token Changes

Part of Visual Regression & Token Drift. The aim is a snapshot suite that runs in under a minute on a token change, produces no false positives, and tells a reviewer which token caused each difference.

Pipeline from a changed token to a scoped snapshot run A five-stage pipeline: detect changed tokens, look them up in the usage index, render the affected components in both themes, compare against baselines, and attach the token diff to the report. detect manifest diff look up usage index render both themes compare to baselines report with diff The first two stages are what keep this cheap Without them every commit renders the whole library; with them a typical token change renders four to eight components.
Scoping happens before any browser starts. By the time Playwright runs, the work has already been reduced to the components that can possibly have changed.

Prerequisites Link to this section

  • A component gallery — Storybook, a custom route, or a static page per component — where each component renders in isolation at a stable URL.
  • A token manifest produced by the build, containing the resolved value of every published token.
  • Playwright with a pinned browser version; unpinned browsers are the largest single source of snapshot noise.
  • A theme toggle reachable programmatically, so both themes can be captured in one page visit.

Step-by-Step Implementation Link to this section

Step 1 — Build the token usage index Link to this section

Intent: map each token name to the components that reference it, so a changed token selects its own test set.

// scripts/usage-index.mjs
import { globSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';

const index = {};                       // token → Set(component)

for (const file of globSync('packages/components/**/*.{css,ts,tsx,vue}')) {
  const component = path.basename(path.dirname(file));
  const source = readFileSync(file, 'utf8');
  for (const match of source.matchAll(/var\(\s*(--[\w-]+)/g)) {
    (index[match[1]] ??= new Set()).add(component);
  }
}

writeFileSync('dist/usage-index.json', JSON.stringify(
  Object.fromEntries(Object.entries(index).map(([k, v]) => [k, [...v].sort()])), null, 2));

Why this works: the index is derived from source rather than declared, so a component that starts using a token is covered on the next build with no test change. It is the same scan the orphan audit performs, so building it once and writing it to disk serves both consumers.

Step 2 — Resolve the affected component set Link to this section

Intent: turn a list of changed token names into the list of component URLs to visit.

// scripts/affected.mjs
import { readFileSync } from 'node:fs';

const index = JSON.parse(readFileSync('dist/usage-index.json', 'utf8'));
const changed = JSON.parse(readFileSync('dist/changed-tokens.json', 'utf8'));

const affected = new Set();
for (const token of changed) for (const c of index[token] ?? []) affected.add(c);

// A token nobody references is either new or orphaned — worth flagging, not snapshotting.
const unreferenced = changed.filter((t) => !index[t]?.length);
if (unreferenced.length) {
  console.warn(`No component uses: ${unreferenced.join(', ')}`);
}

console.log(JSON.stringify([...affected].sort()));

Why this works: the warning for unreferenced tokens catches a case the snapshot suite would otherwise hide. A token changing with no component consuming it is either a token about to be used or one that should have been deleted, and both are worth knowing about at the moment of change.

Step 3 — Make the render deterministic Link to this section

Intent: remove every source of difference that is not the component itself.

// tests/snapshot.setup.mjs
export async function prepare(page) {
  await page.emulateMedia({ reducedMotion: 'reduce' });   // no transitions mid-capture
  await page.addStyleTag({ content: `
    *, *::before, *::after {
      animation-duration: 0s !important;
      animation-delay: 0s !important;
      transition-duration: 0s !important;
    }
    ::-webkit-scrollbar { display: none; }
  `});
  await page.evaluate(() => document.fonts.ready);
  await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => r())));
}

Why this works: these four measures address the four recurring noise sources — animation, scrollbars, font loading and unsettled layout. Applying them as a shared helper rather than per test means a new test cannot forget them, which is how snapshot suites acquire intermittent failures.

Step 4 — Capture both themes in one visit Link to this section

Intent: halve the page loads, and guarantee the two themes are captured from an identical DOM.

// tests/components.spec.mjs
import { test, expect } from '@playwright/test';
import { prepare } from './snapshot.setup.mjs';

const components = JSON.parse(process.env.AFFECTED ?? '[]');

for (const component of components) {
  test(`${component} renders consistently in both themes`, async ({ page }) => {
    await page.goto(`/gallery/${component}/`);
    await prepare(page);

    await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'light'));
    await expect(page.locator('#component-root')).toHaveScreenshot(`${component}-light.png`, {
      maxDiffPixelRatio: 0.002,
    });

    await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
    await expect(page.locator('#component-root')).toHaveScreenshot(`${component}-dark.png`, {
      maxDiffPixelRatio: 0.002,
    });
  });
}

Why this works: capturing the element rather than the viewport keeps the image small and independent of page chrome. Setting the attribute directly rather than clicking the toggle keeps the test focused on rendering rather than on the toggle’s behaviour, which is covered by its own test.

Element capture compared with full-page capture Two capture strategies compared on image size, sensitivity to unrelated changes, and what each one is able to catch. Element capture small images, fast comparison unaffected by page chrome diff points at one component misses: layout interactions between components Full-page capture catches wrapping and overflow catches stacking problems large images, slow comparison fails on: any content change anywhere on the page
Element capture for the component library, one or two full-page captures for the routes where layout interaction actually matters. Using only the second is how suites become unmaintainable.

Step 5 — Attach the token diff to the failure report Link to this section

Intent: make a reviewer able to classify a failure without opening the images.

// playwright.config.mjs — a reporter that prepends the token diff.
import { readFileSync, existsSync } from 'node:fs';

class TokenContextReporter {
  onEnd() {
    if (!existsSync('dist/changed-tokens-detail.json')) return;
    const changes = JSON.parse(readFileSync('dist/changed-tokens-detail.json', 'utf8'));
    if (!changes.length) return;
    console.log('\nToken values changed in this pull request:');
    for (const c of changes) console.log(`  ${c.name}: ${c.from}${c.to}`);
  }
}

export default { reporter: [['list'], [TokenContextReporter]] };

Why this works: the two pieces of information a reviewer needs — what changed visually and what changed in the tokens — are produced by different tools and are useless apart. Printing them in the same output is a five-line change that removes most of the review effort.

Step 6 — Update baselines in the same pull request Link to this section

Intent: a baseline should never be approved separately from the change that caused it.

# Regenerate only the affected snapshots, then review the image diff in the PR.
AFFECTED="$(node scripts/affected.mjs)" npx playwright test --update-snapshots --project=components
git add tests/__screenshots__ && git commit -m "chore: update snapshots for spacing change"

Why this works: committing the images alongside the token change means the review shows both, and the person approving the new baseline is the person who understands why it changed. Hosted approval workflows separate those, which is convenient until a baseline is approved by someone who has no idea what caused it.

Snapshot matrix size by axis A table showing how the number of snapshots multiplies across components, themes, viewports and brands, with a recommended selection for each axis. axis tempting what actually earns its cost components all 120 only those using changed tokens themes both both — this is the point of the suite viewports × brands 3 × 6 = 18 one representative of each, nightly
The matrix multiplies fast. Themes are the axis worth paying for on every run; viewports and brands belong in a nightly job.

Verification Link to this section

Prove the suite catches what it is for. In a scratch branch, change one spacing token by four pixels and run the pipeline end to end. Three things should happen: the manifest diff lists exactly that token, the affected set contains the components that read it, and their snapshots fail with a visible difference. If the third does not happen, the components are probably not reading the token — which is itself worth knowing.

Then prove it does not fire spuriously. Run the suite twice on an unchanged commit. Any difference between two identical runs is environmental noise, and it should be zero after the determinism measures in step 3. A suite with even a small non-zero flake rate will be re-run reflexively within weeks, at which point it has stopped being a gate.

Troubleshooting Link to this section

Symptom Likely cause Fix
Every text-bearing snapshot differs slightly Browser version changed, altering antialiasing Pin the Playwright browser version; regenerate baselines deliberately when upgrading
Snapshots differ between local and CI Different platform rendering fonts differently Generate baselines in CI only, or run the suite in the same container locally
A component’s snapshot is blank The gallery route rendered before styles or data arrived Wait for a component-specific ready signal rather than a fixed timeout
The affected set is empty on a real token change The usage index was built before the component started using the token Rebuild the index in the same job, not from a cached artefact
Diffs appear on components unrelated to the change A shared layout wrapper is inside the captured element Capture a tighter selector that contains only the component

The quality of a snapshot suite is mostly determined by the gallery it renders, not by the test harness. Three properties make a gallery snapshot-friendly, and retrofitting them later is substantially harder than building them in.

One component per URL, with no surrounding chrome. A gallery page that renders a navigation bar and a sidebar around each component bakes those into every snapshot, so a change to the navigation fails every component’s test simultaneously.

Deterministic content. Component examples should use fixed text, fixed dates and fixed data. A card example that renders “3 minutes ago” produces a different image every run, and the usual fix — mocking the clock in the test — is a workaround for a gallery that should not have had a clock in it.

Every meaningful state on the page. A button gallery that shows only the default state cannot catch a regression in the hover or disabled treatment. Rendering all states in one page, in a predictable grid, gives one image per component that covers the whole surface — which is both better coverage and fewer images to manage.

A gallery with those three properties makes the test code almost trivial, which is the right distribution of complexity: the gallery is a durable artefact the team looks at, and the test harness is plumbing.

Migration Note Link to this section

Introducing a snapshot suite into a codebase with none is best done narrowly. Pick the five components where a token change is most likely to cause visible damage — usually button, input, card, dialog and table — and cover only those. That set catches most real regressions, runs in seconds, and gives the team experience of the review workflow before the matrix grows.

Expand by evidence rather than by ambition: when a regression escapes to production, add the component it affected. A suite that grew that way covers the things that actually break, and every entry in it has a story attached, which makes it far more likely to be maintained than one generated from the component list on day one.