Masking dynamic content in visual snapshots

This section sits under visual regression and snapshot strategies and covers the single largest source of false diffs: content that legitimately changes between runs. A timestamp, a spinner mid-rotation, a randomly seeded avatar, or a live API response will each produce a pixel difference every time, and no tolerance threshold can tell that difference apart from a real regression.

Prerequisites

The order of operations

The techniques on this page are not alternatives — they are a sequence, and applying them out of order costs sensitivity you do not get back.

Freeze first. Anything derived from data or the clock can be made deterministic at source. A component rendering “3 minutes ago” is a pure function of a timestamp and the current time; fix both and the output is fixed. This costs nothing: the snapshot still compares every pixel.

Disable motion second. A transition or spinner is not random, only untimed. Emulating reduced motion and zeroing durations makes the component reach its final state before the shot, which is both deterministic and closer to what a reviewer wants to see.

Mask third, and narrowly. Some regions genuinely cannot be frozen — a third-party map tile, an ad slot, a video frame. Masking excludes that box from the comparison, which means anything inside it stops being checked. That is an acceptable trade for a region you do not control and a bad one for a region you do.

Raise the threshold last, if ever. A threshold applies to the whole image, so raising it to accommodate one noisy element blinds every other element by the same amount.

Five sources of noise, in the order they bite A boundary listing five sources of pixel noise. Time — the clock, relative dates and countdowns — and motion — transitions, spinners and carousels — are highlighted as the two most common. Randomness from avatars, generated ids and shuffled ordering comes next, then remote data from live API responses, then rendering differences from fonts and device pixel ratio. Sources of pixel noise, and the order to remove them Time clock, relative dates, countdowns Motion transitions, spinners, carousels Randomness avatars, ids, shuffled order Remote data live API responses Rendering fonts, device pixel ratio Freeze what you can; mask only what cannot be frozen

Step-by-step implementation

Step 1 — Freeze the clock

// Playwright: fix time before the component renders
test.beforeEach(async ({ page }) => {
  await page.clock.setFixedTime(new Date('2026-01-15T09:30:00Z'));
});

Verify it works: a component rendering a relative timestamp should show the same string on two consecutive runs. If it still moves, the clock was set after the component read it — move the call before navigation.

Step 2 — Make the data a fixture

// the story declares its data, so nothing is fetched at capture time
export const Loaded: Story = {
  parameters: {
    msw: { handlers: [http.get('/api/orders', () => HttpResponse.json(fixedOrders))] },
  },
};

Verify it works: reload the story ten times and confirm the rendered content is byte-identical. A list whose order changes is usually sorted on a field with ties — sort on a stable key as well.

Step 3 — Remove motion

await expect(page.locator('#storybook-root')).toHaveScreenshot('card.png', {
  animations: 'disabled',       // Playwright freezes CSS animations and transitions
});
// belt and braces, for JS-driven motion Playwright cannot pause
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.addStyleTag({
  content: '*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }',
});

Verify it works: capture the same story twice in one run. Any remaining diff is motion Playwright could not reach — usually a canvas or a JS-animated element.

Step 4 — Mask what remains

await expect(page.locator('#storybook-root')).toHaveScreenshot('profile.png', {
  mask: [page.locator('[data-testid="map-tile"]')],
  maskColor: '#FF00FF',        // an obvious colour, so a reviewer sees the hole
});

Verify it works: the diff image should show a solid block exactly where the map was, and nothing else should have changed.

Step 5 — Re-measure the noise floor

# same commit, three runs — everything reported is noise
npx playwright test --grep @visual --repeat-each=3

Verify it works: the per-story diff percentages should now cluster near zero. A story still reporting a percentage after all four steps has a source of variance you have not found yet, and raising its threshold would only hide it.

Configuration reference

Five techniques, and what each costs Five rows. Freezing the clock uses Playwright's fixed-time clock or a story-level fixture and costs nothing, making it the best option. Disabling animation uses Playwright's animations setting or Chromatic's pause-at-end and also costs nothing. Masking a region uses a Playwright mask locator or a Chromatic ignore attribute and costs blindness inside the box. Delaying the capture is slower and still racy. Raising the threshold costs blindness everywhere and should be last. Technique Playwright Chromatic Cost Freeze the clock clock.setFixedTime story-level fixture none — best option Disable animation animations: 'disabled' pauseAnimationAtEnd none Mask a region mask: [locator] data-chromatic="ignore" blind inside the box Delay the capture waitFor before shot delay parameter slower, still racy Raise the threshold maxDiffPixelRatio diffThreshold blind everywhere
Option Tool Effect
clock.setFixedTime Playwright Pins Date.now and new Date() for the page
animations: 'disabled' Playwright Finishes CSS animations and transitions before capture
mask / maskColor Playwright Paints over locators before comparing
stylePath Playwright Injects a stylesheet used only for captures
pauseAnimationAtEnd Chromatic Captures animations at their final frame
data-chromatic="ignore" Chromatic Excludes an element from comparison
delay Chromatic Waits before capturing (last resort)

Step 6 — Seed randomness rather than masking it

Content that looks random usually is not: it comes from a generator with a seed, and a seed is something a test can supply.

// the component picks an avatar from a hash of the user id — so fix the id
export const Loaded: Story = {
  args: { users: [{ id: 'u-0001', name: 'Ada Lovelace' }, { id: 'u-0002', name: 'Grace Hopper' }] },
};
// where randomness is genuinely internal, inject the generator
export const Shuffled: Story = {
  args: { random: mulberry32(42) },   // a seeded PRNG, not Math.random
};

Verify it works: capture the story twice and diff the two images directly. Identical bytes mean the randomness is now under control; anything else means a second unseeded source remains.

Step 7 — Capture the state you meant, not the state you got

A component that loads then settles has at least two renderable states, and a capture taken without waiting will sometimes get the first one.

// wait for the state the snapshot is about, then capture
await expect(page.getByRole('table')).toBeVisible();
await page.waitForFunction(() => document.fonts.ready.then(() => true));
await expect(page.locator('#storybook-root')).toHaveScreenshot('orders-table.png');

Verify it works: the baseline should show the loaded table, never a skeleton. A baseline containing a skeleton is the clearest sign this step is missing — and it is worth deleting and regenerating rather than living with, because every future comparison inherits it.

Common pitfalls

Masking instead of freezing. A masked timestamp is a timestamp nobody checks. Freezing the clock keeps it in the comparison, which means a change to its formatting is still caught.

Masks that are too large. Masking the card that contains a spinner also masks the card’s border, padding and text. Mask the spinner.

Relying on delay to outrun an animation. A delay is a guess about duration, exactly like a fixed sleep in a unit test. It is slower on every run and still loses the race on a busy machine.

Freezing time to “now”. Using the current date as the fixed time produces a snapshot that is deterministic within a run and different tomorrow. Pick a literal date.

Freeze first, mask second A decision node asking whether the value can be made deterministic. Data can be frozen with a fixture or a fixed clock. Motion can be disabled with reduced-motion emulation and zero durations. A third-party iframe cannot be frozen and must be masked with the smallest possible box. Something live by design is also masked, and asserted another way — with a query rather than with pixels. Can the value be made deterministic? Freeze it a fixture, a fixed clock yes — it is data Disable it reduced motion + zero duration yes — it is motion Mask the region smallest possible box no — third-party iframe Mask the region and assert it another way no — live by design

What masking costs, measured

It is easy to treat masking as free because it makes a red run green. Putting a number on what it removes changes how readily it gets reached for.

A mask excludes its rectangle from both the numerator and the denominator of the diff calculation. A card that is 400 by 300 pixels, with a 120 by 120 avatar masked, has lost 12% of its area from comparison — and that 12% includes the avatar’s border radius, its ring, and whatever spacing separates it from the text beside it. A regression in any of those now ships.

The same arithmetic explains why a mask should be tight. Masking the whole card to hide the avatar removes 100% of the comparison, at which point the snapshot asserts nothing and would be better deleted than kept, since a deleted snapshot at least does not imply coverage.

There is a second cost, harder to measure and more damaging: masks are inherited. A component with a masked region that gets composed into a page-level shot carries the mask upward, and the page shot then has a hole in it that nobody chose. Keeping masks on leaf components, and preferring frozen data at every level above, stops the exclusions compounding.

The practical rule that falls out of this is worth stating plainly: a mask should be the smallest rectangle that covers content you genuinely cannot control, and every mask larger than that is a regression you have agreed in advance not to catch.

Integration point

Masking sits between capture and comparison, so it touches both sides of the visual pipeline. The determinism it depends on comes from the cross-browser matrix section’s pinned container, and what it leaves behind is measured by pixel diff algorithms. Once a story is genuinely stable, baseline management becomes tractable, because a diff means something. The same freezing techniques make responsive viewport testing possible at all — a layout captured at five widths multiplies any residual noise by five. In component tests the equivalent discipline is covered by async behaviour and timers.

Recording what was masked, and why

A mask is invisible in the baseline it produces: the image simply has a coloured rectangle where content used to be, and six months later nobody remembers whether that was deliberate. Two habits make masks auditable.

Use an obvious mask colour. Playwright’s default is a solid pink, and there is no reason to change it — a reviewer looking at a diff should be able to see instantly that a region was excluded rather than mistaking a flat block for a rendering bug.

Name the mask by the reason, not the element. [data-visual-mask="third-party-map"] in your own markup is greppable, survives a vendor upgrade, and states the justification. A locator that reaches into a dependency’s class names states nothing and breaks silently.

The auditing habit that matters most is periodic: list every mask in the suite and ask whether each is still necessary. Masks are added under pressure, when a diff is blocking a merge, and that is exactly the moment when masking is chosen over freezing because it is faster. Revisiting them later — when nothing is blocked — usually converts several back into fixtures, and each conversion returns a region to being checked.

A useful forcing function is to treat the mask list the same way as the accessibility exclusion list: one file, a comment per entry, and a test asserting the count so that adding one shows up in code review.

FAQ

Is masking a region the same as lowering the threshold for it?

No, and the difference matters. A mask removes a region from the comparison entirely, leaving every other pixel judged at full sensitivity. A lowered threshold applies to the whole image, so it weakens the comparison everywhere in order to accommodate one element. Masking is targeted; a threshold change is not.

What should I do about content that is live by design?

Mask it in the visual tier and assert it in another one. A stock ticker’s pixels are not meaningful, but “the ticker renders a number and a direction arrow” is a perfectly good play function assertion. Splitting the claim across tiers keeps both honest.

How do I know whether a story is stable enough to gate on?

Run it several times against an unchanged commit and look at the reported diff percentages. If they cluster near zero, the story is stable and a tight threshold is meaningful. If they scatter, something is still varying — and the answer is to find it, not to pick a threshold above the scatter.

Does freezing the clock break components that poll?

It changes their behaviour, which is usually what you want for a snapshot: a component that refreshes every thirty seconds should be captured in a known state rather than whichever refresh happened to be in flight. Where the polling itself is the subject, advance the fixed clock deliberately between captures rather than letting real time drive it.

Should the mask list be shared between Playwright and Chromatic?

Share the intent, not the syntax. Marking the element in your own markup — a data-visual-mask attribute on the wrapper — gives both tools something to target: Playwright takes a locator built from it, and Chromatic’s ignore attribute can sit on the same element. What you avoid is two independent lists of vendor selectors that drift apart, so that a region is excluded in one tool and compared in the other.

Does disabling animations change what the component would look like to a user?

It changes when, not what. Disabling animation makes the component jump to its final state, which is the state a user sees a fraction of a second later anyway — and the state a snapshot is meant to record. What it does not capture is whether the animation itself is correct, and that is genuinely outside what a still image can assert. A play function checking that a class is applied, or a short video capture in an end-to-end run, covers the motion itself.