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.
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
| 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.
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.
Related
- Tolerance Thresholds — the budget a stabilised story is judged against
- Pixel Diff Algorithms — measuring the noise floor these techniques reduce
- Responsive Viewport Testing — the multiplier that makes residual noise expensive
- Async Behaviour and Timers — the same freezing discipline applied to component tests
- Visual Regression & Snapshot Strategies — the parent guide this section sits under