Masking Dynamic Regions in Screenshot Tests
Screenshot tests compare pixels, and many pages contain pixels that are meant to change: a “last updated 3 minutes ago” label, a user’s avatar, a carousel on autoplay, a map tile, a live stock count, a third-party embed. Each one makes a visual test fail on every run for reasons that have nothing to do with a regression, and a visual suite that fails constantly is soon ignored. The usual reaction — raising the pixel threshold until the failures stop — also hides the real regressions the suite exists to catch. This guide covers the better options, in order of preference: make the content deterministic, hide it with a stylesheet, mask it with Playwright’s mask option, or screenshot a narrower element. It also covers the cost of masking too much, because a masked region is a region nobody is checking. It belongs to visual regression testing.
Root Cause Analysis
Dynamic content falls into three groups, and each wants a different treatment. Content derived from the clock or random values — relative timestamps, generated IDs, shuffled recommendations — is under the application’s control and can be made deterministic by fixing the clock and seeding data. Content from external systems — ads, maps, embedded videos, avatars served by another domain — is outside the application’s control and should be stubbed at the network level or masked. And content that is genuinely animated — spinners, carousels, skeleton shimmer — should be stopped, not masked, because its resting appearance is part of the design.
The common mistake is to reach for the broadest tool first. A high maxDiffPixelRatio makes every test tolerate a certain amount of change anywhere on the page, which means a button that shifts by a few pixels or a colour that changes subtly passes too. Masking is narrower, but a mask over a whole card hides changes to the card’s padding, border and typography as well as the timestamp inside it. The goal is the smallest intervention that removes the noise.
Reproducible Setup
An account dashboard with a relative “last sign-in” time, a remote avatar, a recommendations carousel, and an embedded map of the delivery address.
// tests/visual/fixtures.ts
import { test as base, expect } from '@playwright/test';
export const test = base.extend({
page: async ({ page }, use) => {
await page.clock.install({ time: new Date('2026-03-02T09:30:00Z') }); // fixed clock
await page.route('**/api/recommendations', (r) => r.fulfill({ path: 'tests/visual/data/recommendations.json' }));
await page.route('https://avatars.example-cdn.com/**', (r) => r.fulfill({ path: 'tests/visual/data/avatar.png' }));
await use(page);
},
});
export { expect };
A shared stylesheet stops animations and hides a few known-noisy elements for every screenshot.
/* tests/visual/screenshot.css */
*, *::before, *::after { animation: none !important; transition: none !important; caret-color: transparent !important; }
[data-visual="hide"] { visibility: hidden !important; }
Implementation
Step 1 — Make clock-derived content deterministic. With the clock fixed and the API returning a fixed last-sign-in time, “Last signed in 2 hours ago” renders identically on every run and can be compared rather than masked.
test('dashboard header', async ({ page }) => {
await page.goto('/account');
await expect(page.getByTestId('account-header')).toHaveScreenshot('account-header.png', {
stylePath: 'tests/visual/screenshot.css',
});
});
Step 2 — Stub external images and data instead of masking them. The route handlers above replace the remote avatar and recommendations with fixture files, so both regions are still compared — a layout change around the avatar is caught.
Step 3 — Mask what cannot be controlled. The embedded map renders tiles from a third party whose appearance changes. Mask it with a locator; Playwright paints a solid box over its bounds.
test('delivery panel', async ({ page }) => {
await page.goto('/account');
const panel = page.getByRole('region', { name: 'Delivery address' });
await expect(panel).toHaveScreenshot('delivery-panel.png', {
mask: [panel.locator('iframe[title="Map"]')],
maskColor: '#ff00ff',
stylePath: 'tests/visual/screenshot.css',
});
});
A vivid mask colour makes masked regions obvious when reviewing baselines, so nobody mistakes a masked area for a blank one.
Step 4 — Stop carousels at a known slide. Pause autoplay through a query parameter or a test hook rather than masking the carousel; the resting slide is part of the design.
await page.goto('/account?autoplay=off');
await page.getByRole('button', { name: 'Go to slide 1' }).click();
await expect(page.getByRole('region', { name: 'Recommended for you' })).toHaveScreenshot('recs.png');
Step 5 — Hide rather than mask when layout must stay. visibility: hidden through the [data-visual="hide"] attribute keeps the element’s space but removes its pixels, which is useful for small elements such as a live “online now” badge whose presence varies. Prefer this for elements inside text flow, where a mask box can look like a layout change in the diff.
Step 6 — Review masks like code. Keep masks in the test file next to the screenshot they affect, and ask in review whether each could instead be made deterministic. A mask added to fix one flaky run tends to stay for years.
Step 7 — Audit masks periodically. Masks accumulate. Every few months, list them — a simple search for mask: and data-visual="hide" across the visual test folder is enough — and ask of each one whether the underlying cause still exists. The map may have been replaced by a static image, the ad slot removed, the avatar moved to the application’s own storage. Each mask that can be deleted returns part of the page to real comparison, and the audit often reveals masks that were added to silence a flaky run whose real cause, such as a missing fixed clock, was fixed elsewhere long ago.
It also helps to record why each mask exists in a one-line comment beside it: “third-party map tiles change daily” or “presence badge depends on websocket state”. The comment turns the audit from guesswork into a quick check, and it discourages masks added without a reason, because an empty explanation is conspicuous in review.
Treat the number of masks as a rough health signal for the visual suite. A suite whose mask count only ever grows is drifting toward checking nothing; one where masks are regularly removed as the underlying causes are fixed is a suite whose baselines stay meaningful.
Verification
Run each visual test several times in a row with no code changes; every run must pass with zero differing pixels, not merely under a threshold.
npx playwright test tests/visual --repeat-each=5 --reporter=line
Then confirm the masks are narrow enough to catch real changes: add two pixels of padding to the delivery card in the stylesheet and rerun. The delivery-panel test must fail, with a diff showing the change around, not inside, the magenta mask. If it passes, the mask is hiding part of what it should protect.
Finally, open the baseline images and look at them. Masked regions should be small and obviously intentional; a baseline that is mostly magenta is a test that checks very little.
Troubleshooting
Symptom: the mask box appears in a slightly different place each run. Diagnosis: the masked element’s size depends on content that loads late, such as an image without dimensions. Fix: give the element explicit dimensions, and wait for it to be visible before the screenshot.
Symptom: fonts differ between local runs and CI. Diagnosis: different font availability or rendering, not dynamic content. Fix: run visual tests in the same container image everywhere; see managing visual baselines across browsers.
Symptom: the relative time still changes. Diagnosis: the time is computed on the server, which uses the real clock. Fix: return a fixed time from the mocked API, or let the server accept a clock override in test environments.
Symptom: text inputs show a blinking caret in some baselines. Diagnosis: the caret is captured mid-blink. Fix: caret-color: transparent in the screenshot stylesheet, as in the setup; Playwright also hides carets by default for toHaveScreenshot.
FAQ
Is maxDiffPixels ever appropriate?
A tiny absolute allowance, such as a handful of pixels for anti-aliasing differences, is reasonable. A ratio large enough to absorb dynamic content is not, because it absorbs real regressions too.
Should I screenshot whole pages or components?
Components and regions, mostly. Narrow screenshots contain less dynamic content, need fewer masks, and point directly to what changed. Keep a small number of full-page screenshots for layout.
How do I handle user-generated content in production-like data?
Use a fixed fixture set for visual tests instead of copies of real data. Real data changes, and it often contains lengths and characters that make baselines unstable for reasons unrelated to the change under review.
Does Storybook help with dynamic regions?
Stories are a natural place to fix data and stop animation, so many dynamic regions never appear at all. See catching CSS regressions in Storybook.
Can masks hide accessibility regressions?
Yes — a masked region’s contrast, focus styles and text are not compared at all. Cover masked areas with separate component tests or accessibility checks so they are protected by something.
Related
- Back to Visual Regression Testing
- Reducing flaky screenshots with deterministic rendering — fonts, animation and timing in depth.
- Snapshot testing with Playwright screenshots — the basics these options build on.
- Time and date control strategies — the clock-control ideas used here.