Masking timestamps and dates in Playwright screenshots

This page is part of the dynamic content masking section of the visual regression guide, which covers making a capture deterministic before it is compared.

The specific problem: a card renders “2 minutes ago”. Every run renders a different string, so every run produces a diff, and the story has been excluded from the suite because nobody could keep it green.

Problem statement

A relative timestamp is a pure function of two things: an event time and the current time. Fix neither and the output changes continuously; fix only the event time and it still changes as the clock moves.

The symptom is a story whose diff percentage varies with how long ago the baseline was captured — small at first, large after a day, and occasionally enormous when the string crosses a boundary from minutes to hours.

Root cause

The component reads the current time from the runtime, not from its props. Date.now() inside a formatter is invisible to the story’s arguments, so no amount of fixture work fixes it — the value is being read from the environment at render.

Masking the region works, and costs the comparison: a masked timestamp is one nobody checks, so a change to its format, colour, or position ships unnoticed. Freezing the clock keeps it in the image and under comparison.

One unchanged component, four different snapshots A timeline of four runs of the same unchanged commit. The first renders two minutes ago, the second three minutes ago a minute later, the third one hour ago after a rerun, and the fourth yesterday the following morning. Nothing about the component changed; only the clock did. '2 minutes ago' run 1 captured Tuesday '3 minutes ago' run 2 one minute later '1 hour ago' run 3 after a rerun 'yesterday' run 4 the next morning Every one of these is a diff against the baseline

Minimal reproduction

// OrderCard.tsx — reads the clock at render time
export function OrderCard({ order }: { order: Order }) {
  return (
    <article>
      <h3>{order.reference}</h3>
      <time dateTime={order.placedAt}>{formatRelative(order.placedAt, Date.now())}</time>
    </article>
  );
}
// the fixture pins the order, but not "now"
export const Recent: Story = { args: { order: { reference: 'ORD-4192', placedAt: '2026-01-15T09:28:00Z' } } };

Step-by-step fix

1. Fix the clock before the page renders

// order-card.spec.ts
test.beforeEach(async ({ page }) => {
  // a literal date — never new Date(), which makes the baseline age
  await page.clock.setFixedTime(new Date('2026-01-15T09:30:00Z'));
});

What this does: pins Date.now(), new Date() and performance.now() for the page, so the formatter computes the same result on every run.

2. Set it before navigation, not after

test('order card, recent', async ({ page }) => {
  await page.clock.setFixedTime(new Date('2026-01-15T09:30:00Z'));
  await page.goto('/iframe.html?id=ordercard--recent');       // clock is already fixed
  await expect(page.locator('#storybook-root')).toHaveScreenshot('order-card-recent.png');
});

What this does: guarantees the component’s first render already sees the fixed time. A clock set after navigation misses any date captured during mount.

3. Pin the event time in the fixture too

export const Recent: Story = {
  args: { order: { reference: 'ORD-4192', placedAt: '2026-01-15T09:28:00Z' } },
};

What this does: with both ends fixed, the rendered string is a constant — “2 minutes ago” — and stays that way regardless of when the suite runs.

4. Cover the boundaries deliberately

// each story pins a different offset from the same fixed now
export const MinutesAgo: Story = { args: { order: at('2026-01-15T09:28:00Z') } };
export const HoursAgo:   Story = { args: { order: at('2026-01-15T06:30:00Z') } };
export const Yesterday:  Story = { args: { order: at('2026-01-14T09:30:00Z') } };
export const LastYear:   Story = { args: { order: at('2025-01-15T09:30:00Z') } };

What this does: turns the formatter’s branches into four stable baselines, so a change to how hours or days are rendered is caught rather than being whatever the clock happened to produce.

5. Handle a component that is genuinely live

// a countdown updates every second; capture it at a known tick
await page.clock.setFixedTime(new Date('2026-01-15T09:30:00Z'));
await page.goto('/iframe.html?id=countdown--running');
await page.clock.runFor(5000);        // advance deliberately, then capture
await expect(page.locator('#storybook-root')).toHaveScreenshot('countdown-5s.png');

What this does: keeps a ticking component under comparison by choosing the tick, rather than masking it and losing the whole region.

Verification

Repeating the run proves the fix A repeated Playwright run over six stories. Before fixing the clock, the OrderCard story produced three different images across three runs, with 0.41, 0.44 and 1.98 percent of pixels differing. After setting a fixed time, all three runs report zero. Eighteen tests pass across three repetitions of six stories. playwright test --repeat-each=3 --grep @visual before: OrderCard 3 runs, 3 different images 0.41%, 0.44%, 1.98% differing pixels after clock.setFixedTime: 0.00%, 0.00%, 0.00% 18 passed (3 runs x 6 stories)

Repeating the run against an unchanged commit is the verification that matters. Three identical images mean the clock is genuinely fixed; any variation means a second source of time is still being read — often a nested component, or a library formatting on its own.

Edge cases and caveats

Where the date comes from decides the fix Five rows. A date from Date.now inside the component is fixed with a fixed clock and stays compared. A date arriving as an API field is fixed with a fixture carrying a literal date. A date created inside a formatter is again fixed with the clock. A server-rendered string is fixed with a fixture. Only a third-party widget's own clock, which you cannot reach, requires masking — and that region then stops being compared. Source of the date Freeze with Still compared? Date.now() in the component clock.setFixedTime yes A field from the API a fixture with a literal date yes new Date() in a formatter clock.setFixedTime yes A server-rendered string a fixture yes A third-party widget's clock mask the region no
  • Time zones. A fixed instant still renders differently under different TZ values. Pin the timezone in the container or the Playwright config alongside the clock, or a runner in another region produces a different string from the same instant.

  • Locale. The same applies to Intl formatting. Pin the locale explicitly rather than inheriting the runner’s.

  • Dates that cross a boundary. A fixture pinned two minutes before a fixed now is stable; one pinned 59 minutes before is one rounding change away from switching to “1 hour ago”. Choose offsets that sit comfortably inside a bucket.

Auditing a suite for hidden clock reads

A component that renders no visible date can still read the clock, and the resulting instability is harder to trace because there is nothing obvious to point at. A cache key derived from the day, a greeting that changes at noon, an “expires soon” badge that appears within an hour of a deadline — each produces an occasional diff with no visible timestamp to blame.

The quickest audit is to make clock reads fail. Stubbing Date.now to throw inside a story and running the suite surfaces every component that reads it, including the ones several layers down in a dependency:

await page.addInitScript(() => {
  const realNow = Date.now;
  Date.now = () => { console.warn('clock read'); return realNow(); };
});

Collecting those warnings per story gives you the list. Most entries will be benign — a library logging a duration — but the ones that feed rendering are exactly the stories whose baselines will drift.

The second audit is temporal rather than structural: rerun the visual suite against an unchanged commit at a deliberately different time, such as a few hours later or with the container’s timezone shifted. Anything that diffs is time-dependent, whether or not it displays a date. This catches the noon-boundary greeting and the expiry badge that structural inspection misses, and it takes one scheduled job to run.

FAQ

Why not just mask the timestamp?

Because a masked region is one nobody checks. The timestamp’s colour, weight, alignment and format are all things a regression could break, and all of them are invisible once the region is painted over. Freezing costs one line in the test setup and keeps every one of those under comparison.

Should the fixed date be the same across the whole suite?

Yes — pick one instant and put it in a shared constant. Different tests using different “now” values makes fixtures non-portable between them and produces surprising results when a fixture written for one file is reused in another. One constant, imported everywhere, keeps the offsets meaningful.

Does the fixed clock affect animations and timers too?

Playwright’s clock controls timers as well as dates, so an animation driven by setInterval will stop advancing unless you call runFor. That is usually helpful — it is the same freezing you want for motion — but it does mean a component that waits on a timer to reach its final state needs the clock advanced explicitly before capture.

What about a component that renders a date range picker defaulting to this month?

Fix the clock and the default becomes stable, which is the whole point — but choose the fixed date carefully. A date near the end of a month produces a range that behaves differently from one in the middle, and February will differ from March regardless. Pin an unremarkable mid-month date in a 31-day month for the default story, and add explicit stories for the boundaries you care about.