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.
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.
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.
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 |
Component Gallery Design Matters More Than the Test Code Link to this section
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.
Related Link to this section
- Visual Regression & Token Drift — the parent area, including when the value diff alone is enough
- Gating Token Pull Requests with Playwright Snapshots — wiring this suite into a required check without blocking every merge
- Detecting Orphaned and Unused Tokens in CI — the audit that builds the same usage index