Playwright architecture, selector reliability, and advanced interaction patterns.

Masking Dynamic Regions in Snapshots

A screenshot assertion is a byte-level equality check dressed up as a UI test, and almost every real application contains a handful of pixels that refuse to be equal: a relative timestamp that ticks from "2 minutes ago" to "3 minutes ago", a gravatar derived from a seeded email hash, a rotating advertisement slot, a live counter, a randomly generated order reference. Those regions produce a red build every few runs regardless of whether anything actually regressed, and the usual reaction — raising the pixel tolerance until the noise stops — blinds the whole image to genuine defects. The precise instrument is the mask option on toHaveScreenshot(), which paints a solid rectangle over specific elements before the shutter opens so the volatile area contributes identical pixels to both the baseline and the capture. This page covers how masking is actually implemented, when it silently fails, and the two techniques you should reach for before it.

Where the mask overlay is applied during capture A live page with a varying timestamp band, the same page captured with the band painted over, and the stored baseline which was recorded with the same mask. Live render Masked capture Stored baseline nav bar chart 14:03:22 footer nav bar chart masked footer nav bar chart masked footer timestamp varies band is opaque recorded masked The overlay is painted in the page before the PNG is encoded, so both sides match.
Masking happens browser-side during capture, which is why the baseline must be recorded with the same mask list as the comparison run.

Root cause: the comparator has no concept of "ignore this part"

Playwright's screenshot comparator diffs two PNGs with pixelmatch and reports a count of differing pixels; it has no selector awareness and no region exclusions, so a three-character timestamp change and a collapsed navigation bar look identical to it. The only way to exclude a region is to make the region render the same pixels every time, which means the exclusion has to happen inside the browser before the image is encoded. That is exactly what mask does: Playwright inserts absolutely positioned overlay boxes at the bounding boxes of the masked locators, takes the shot, then removes them. Everything on this page follows from that mechanism, and from the fact that the mechanism operates on bounding boxes rather than on content. This deep dive sits under Visual Regression Testing, part of Debugging & Test Observability.

Minimal reproducible example

The test below is the shape almost every team writes first: navigate, wait for the component, assert the screenshot. It passes the first time because Playwright writes the baseline, and then fails intermittently forever, because the summary card renders Last updated 14:03:22 from the machine clock and the activity list renders avatars whose URLs contain a per-seed hash.

import { test, expect } from '@playwright/test';

test('dashboard matches the visual baseline', async ({ page }) => {
  await page.goto('/dashboard');

  // Web-first assertion: guarantees the card is attached and painted
  // before the shutter opens. Without it the shot can catch a skeleton.
  await expect(page.getByTestId('summary-card')).toBeVisible();

  // First run writes dashboard.png and fails with "A snapshot doesn't
  // exist ... writing actual." Every later run diffs against that file.
  await expect(page).toHaveScreenshot('dashboard.png');
});

The second run fails with 418 pixels (ratio 0.01 of all image pixels) are different., and the reported paths point at dashboard-expected.png, dashboard-actual.png and dashboard-diff.png under test-results/. Opening the diff shows two bright patches — the clock digits and the avatar column — and nothing else. That is the signature of a volatility problem rather than a regression: the differing pixels are contiguous, small, and located exactly where you would expect non-deterministic content.

Step-by-step fix

  1. Identify the volatile regions from the diff artifact, not from memory. Open test-results/<test-dir>/dashboard-diff.png and note every patch of highlighted pixels. Map each patch to a DOM element using the DOM snapshot in the trace, which you can inspect with the workflow in Analyzing Test Failures with the Playwright Trace Viewer. You want an element-level inventory before you write a single mask, because each one you mask is a region you stop testing.

  2. Freeze everything you control before you hide anything. A masked region is dead weight in the suite; a frozen region still gets asserted. Stub the API responses that feed the component with page.route() — the patterns are in Mocking API Responses with Playwright — and pin the wall clock with page.clock.setFixedTime() so Date.now(), new Date() and Intl.DateTimeFormat all resolve to the same instant on every run. Locale and timezone belong in the project config as well, since a runner in a different zone renders different digits; see Emulating Devices, Locales and Timezones.

  3. Mask only what remains genuinely uncontrollable. Pass an array of locators as mask. Each locator may resolve to many elements and all of them are covered, so page.getByTestId('relative-time') masks every timestamp in a list with one entry. Target the smallest element that contains the volatility — the <span> holding the time, not the card that wraps it.

  4. Choose a maskColor that cannot occur in your UI. The default overlay is bright magenta, which is deliberately loud so reviewers notice it, but if your design system uses magenta the overlay becomes invisible in review and a mask that drifted off its target goes unnoticed. Any CSS colour string works, including rgb() and rgba() with alpha.

  5. Pin the box when the region reflows. The overlay is drawn at the element's measured bounding box, so a timestamp that renders "3 minutes ago" on one run and "12 minutes ago" on another produces a wider overlay and a fresh diff along its right edge — plus a layout shift in every sibling to its right. Masking cannot fix that; CSS can. Use the stylePath option to inject a stylesheet at capture time that gives the element a fixed width and visibility: hidden, which removes the content while preserving the layout box.

  6. Disable animations and hide the text caret. Set animations: 'disabled' so Playwright fast-forwards CSS animations and transitions to their end state and freezes infinite ones, and caret: 'hide' so a blinking cursor in a focused input does not flip a few dozen pixels between runs. Both are frequent contributors to the same intermittent diffs people try to solve with masks.

  7. Move the settings into playwright.config.ts. Options passed inline apply to one assertion; options under expect.toHaveScreenshot apply to every screenshot in the project, including the run that records baselines. Keeping stylePath, animations, caret and maxDiffPixelRatio in the config is what makes recorded and compared images use the same rules — the pairing with sensitivity budgets is covered in Tuning Screenshot Comparison Thresholds.

  8. Re-record the baselines and review the new images. Existing baselines were captured without the overlay, so adding a mask makes the entire masked region differ. Run npx playwright test --update-snapshots=changed, then open every rewritten PNG in the pull request and confirm the overlay sits exactly where you intended. Committing regenerated images unseen is how a mask that covers half a component gets into the repository.

Choosing between freezing, masking and CSS hiding A decision tree that routes controllable data to route and clock stubs, and uncontrollable content to either the mask option or a fixed-size CSS hide. Region differs between runs You control the source an API response or the clock Third party or opaque ads, map tiles, avatars Freeze the data route() plus setFixedTime() Does its bounding box change size between runs? No: mask option Yes: stylePath
Reach for the mask option only after determinism has failed, and swap it for a fixed-size CSS hide whenever the element reflows.

The stabilized test applies all three layers. Note that the mask list contains exactly two entries after the clock and the API have been pinned — everything else in the dashboard is now deterministic and remains under assertion.

import { test, expect } from '@playwright/test';

test('dashboard is visually stable', async ({ page }) => {
  // 1. Pin the clock BEFORE the first navigation so the app's initial
  //    render already sees the frozen instant, not the real one.
  await page.clock.setFixedTime(new Date('2026-07-24T09:00:00Z'));

  // 2. Serve a fixed payload so counters and list ordering never move.
  await page.route('**/api/summary', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ orders: 128, revenue: 40960, currency: 'EUR' }),
    });
  });

  await page.goto('/dashboard');
  await expect(page.getByTestId('summary-card')).toBeVisible();

  await expect(page).toHaveScreenshot('dashboard.png', {
    mask: [
      // Avatar images are fetched from a third-party host we cannot seed.
      page.getByRole('img', { name: /avatar/i }),
      // The ad slot is a cross-origin iframe with rotating creative.
      page.locator('iframe[title="Advertisement"]'),
    ],
    // Green is absent from this design system, so a drifting overlay is loud.
    maskColor: 'rgb(0, 128, 0)',
    // Fast-forward CSS animations and freeze infinite ones at their end state.
    animations: 'disabled',
    // Prevent a blinking caret in the focused search box from flipping pixels.
    caret: 'hide',
  });
});

The stylesheet referenced by stylePath is ordinary CSS and lives beside the tests. It handles the reflow case that masking cannot, and because it is declared once in the config it applies to baseline recording and comparison alike.

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  expect: {
    toHaveScreenshot: {
      // Injected into every page just before capture, then reverted.
      // The stylesheet hides volatile text while pinning its layout box:
      //   [data-testid="relative-time"] {
      //     visibility: hidden; display: inline-block; width: 96px;
      //   }
      stylePath: './tests/screenshot.css',
      animations: 'disabled',
      caret: 'hide',
      // A small budget absorbs sub-pixel anti-aliasing, not content change.
      maxDiffPixelRatio: 0.002,
    },
  },
  use: { viewport: { width: 1280, height: 720 } },
});

Troubleshooting variants

The mask option had no visible effect

The overwhelmingly common cause is a mask locator that resolves to zero elements. Playwright does not error on an empty mask list entry — it masks whatever the locator matches, and matching nothing is a valid outcome — so a renamed test id or a role that never existed produces a silent no-op and an unchanged diff. Assert the count in the same test with await expect(page.getByTestId('relative-time')).toHaveCount(4) before the screenshot, or open the actual capture and look for the overlay colour. The second cause is an element with no layout box: a node that is display: none, detached, or zero-sized has no bounding box to paint over, which matters when the volatile content only becomes visible on hover or after an animation completes. Reliable targeting of these elements is easier with role and accessible-name queries, as argued in getByRole & Accessibility Selectors.

The masked area is itself the thing that differs

Two mechanisms produce this. The overlay is sized from the bounding box at capture time, so any element whose width or height depends on its content — a relative timestamp, a truncated username, a badge counter that crosses from one digit to two — yields a differently sized rectangle each run, and the edges of that rectangle diff. Worse, the size change shifts every sibling laid out after it, so the diff appears far away from the masked region and looks like a genuine regression. The fix is the CSS route from step 5: pin width and height (or min-width) on the element and hide its content with visibility: hidden rather than display: none, which keeps the box in flow. The other mechanism is a mask that lands late — if the element is still animating into position when the overlay is measured, the box is captured mid-transition. Disabling animations removes that class of failure entirely.

It passes locally and fails in CI

Masking is not the variable here; the environment is. Font rasterisation, deviceScaleFactor, GPU compositing and scrollbar width all differ between a developer machine and a Linux container, so a baseline recorded locally will diff on the runner no matter how carefully you mask. Generate baselines inside the same container image the pipeline uses, keep {platform} in your snapshotPathTemplate, and treat the container as the only authority — the workflow is laid out in Managing Screenshot Baselines Across Platforms. If the CI-only diff is a genuine content difference rather than a rendering one, the culprit is usually an unstubbed network call reaching a real service from the runner; recording the traffic once with the approach in Recording and Replaying HAR Files removes the variable.

Verification

Prove the mask works rather than assuming it. Run the test with npx playwright test --repeat-each=10 dashboard.spec.ts and require ten green runs; volatility that survives masking shows up within a handful of repetitions, and a suite that only passes eight times out of ten is still broken. Next, prove the mask is not too wide: open the -actual.png from a passing run and measure how much of the component the overlay covers. If a card is 40 percent painted, you have stopped testing that card and should narrow the locator or switch to freezing the data. Finally, prove the assertion can still fail — introduce a deliberate one-pixel change outside the masked region, such as a padding tweak in a local branch, and confirm the test goes red. A visual test that never fails is indistinguishable from one that is not running, and this three-step check is the same discipline applied to any suspect assertion in Detecting and Fixing Flaky Playwright Tests.

Reading the expected, actual and diff artifacts Three PNG artifacts side by side showing that the masked band is identical while the unmasked avatar row is the only region flagged in the diff. expected.png actual.png diff.png card header masked band avatar row v1 card header masked band avatar row v2 no change no change flagged pixels Only the unmasked row is flagged; the masked band is byte-identical on both sides. All three files are written into the test-results directory on failure.
A correct mask narrows the diff to exactly the regions you left under assertion, which is what makes the artifact reviewable.

Frequently Asked Questions

Does masking change the page my users see?

No. The overlay elements are injected into the live DOM immediately before the capture and removed immediately after, and they exist only in the browser instance the test drives. They are not part of your application bundle and never reach production. The one practical consequence is that any assertion running concurrently against the same page during the capture window could observe the overlays, which is a reason to avoid firing screenshot assertions from parallel promises against a single page object.

Can I mask a region by coordinates instead of by locator?

The mask option only accepts locators, so there is no coordinate form. When the volatile content has no addressable element — a region inside a <canvas>, for example — the usual workaround is to screenshot a locator that excludes the area rather than the full page, or to add a positioned, empty overlay element to the page in test builds and mask that. Coordinate-based exclusion would also be fragile in a way locators are not: any layout change moves the rectangle off its target while the test keeps passing.

Why did all my baselines fail after I added one mask?

Because the baseline was recorded before the overlay existed. The stored PNG contains the real timestamp pixels, the new capture contains a solid rectangle, and every pixel in that rectangle differs. This is expected and requires a single deliberate regeneration with --update-snapshots, after which the images agree. It is also the reason mask configuration belongs in playwright.config.ts rather than scattered inline: a mask that applies to some runs and not others guarantees a permanently red suite.

Should I mask an element or just wait longer for it to settle?

Wait first. A large share of "dynamic" regions are not dynamic at all — they are slow, and the screenshot caught them mid-load. Web-first assertions such as toBeVisible() and toHaveText() before the capture, plus animations: 'disabled', resolve those cases without giving up coverage. Reserve masking for content that is genuinely different on each render even after the page has fully settled; the waiting techniques worth trying first are collected in Handling Dynamic Content.

Back to overview