Animated Tile Layer Stabilization

A weather radar loop, a live traffic overlay, and a particle wind field share a property that quietly breaks every visual regression suite: they never stop changing. Unlike a basemap that fetches its tiles, paints, and settles into a fixed frame, an animated layer advances on its own clock — a new radar frame every few hundred milliseconds, a fresh set of flow particles on every animation tick, a traffic tint that re-fetches on a timer. The map engine’s idle event, the signal most capture gates lean on, either never fires or fires between ticks while the layer is already mutating toward the next frame. Point a screenshot comparator at that surface and every run captures a different moment in the animation, producing a diff that has nothing to do with a code change. This guide covers how to pin these layers to a single, deterministic frame — by fixing the animation clock, stepping to a known frame index through the layer API, or intercepting the temporal tile request — and what to do when none of those is possible.

This guide sits within Dynamic Element Masking & UI Stability, the parent guide for turning a volatile map canvas into a reproducible one. Where its other sections neutralize elements that are merely present at capture time — cursors, popups, attribution reflow — animated tile layers are harder, because the volatility is temporal and self-driving: the layer would produce a different frame even on a perfectly still, fully-loaded map. It also builds directly on the render-completion signals developed in WebGL Idle & Render-Completion Detection, because you cannot pin a frame you cannot detect as painted.

What an animated tile layer actually is

“Animation” on a web map is not one mechanism but three, and each needs a different freeze technique. Getting the classification right is the whole game — a technique that stops a CSS pulse does nothing to a radar loop, and vice versa.

Frame-sequence raster loops. Weather radar, satellite infrared, and precipitation nowcasts are published as a series of raster (or vector) tile sets, one per timestamp. A player component cycles a pointer through the timestamps, swapping the visible source every few hundred milliseconds and often cross-fading between adjacent frames. The tile URLs carry the time index — .../radar/{time}/{z}/{x}/{y}.png — so the data changes every step, not just the styling. This is the class that most resembles a video and the one where a stray capture is most obviously wrong.

Data-driven particle and flow layers. Animated wind, ocean current, and vector-field layers do not swap tiles at all. They read a static velocity grid once, then integrate thousands of particle positions on the GPU on every requestAnimationFrame, trailing and re-seeding them continuously. Nothing in the source is changing — the simulation state is. There is no “frame index” to seek to; there is a clock and a random seed. WebGL wind layers (the mapbox-gl / maplibre particle-layer pattern popularized by Vincent Sarago’s and Andrei Kashcha’s implementations) fall here.

CSS-animated and canvas-animated symbols. Pulsing incident markers, a rotating radar sweep hand, a “live” ping ripple, or a marching-ants selection outline are driven by CSS @keyframes, Web Animations API timelines, or a per-frame canvas redraw. These are the closest to generic web animation — but note the distinction that matters below: freezing them is not the same problem as freezing the temporal data layers, even though a blunt “disable all animation” pass tends to conflate them.

The reason the ordinary gate fails on all three is the same. A settled basemap satisfies map.areTilesLoaded() && !map.isMoving() and fires idle once, deterministically. An animated layer keeps the render loop alive: MapLibre’s map.isMoving() stays false, but the layer schedules its own repaints via map.triggerRepaint() or an external rAF loop, so idle either never fires (particle layers hold the loop open forever) or fires transiently between two radar frames. Either way, “wait for idle then shoot” captures a non-deterministic moment.

Design patterns for pinning a frame

Three patterns freeze an animated layer, in rough order of preference. Prefer the one highest on this list that your layer supports, because each lower option is further from what the user actually sees.

  1. Pin the clock and seed. Replace the layer’s time source and any RNG with a fixed value before the first frame renders. A radar player reading Date.now() to pick its starting frame, or a particle layer seeding positions from Math.random(), becomes deterministic the moment those inputs are frozen. This is the cleanest freeze: the layer renders exactly as it would in production, just always at the same instant.

  2. Step to a known frame via the layer API. Most player components expose an imperative control — player.setTime(t), player.pause(), layer.setFrame(i), or a props/state setter in a React/Vue wrapper. Drive it to a fixed index and stop the loop. This is deterministic and honest, but couples the test to the component’s API surface.

  3. Intercept the temporal tile request. When you cannot reach the clock or the API — a third-party layer, an iframe embed, a minified bundle — pin the layer at the network boundary. Rewrite every timestamped tile request to a single fixed time index so the layer, whatever frame it thinks it is on, always receives the bytes for one moment. The animation still “runs,” but every frame is visually identical.

When all three fail — a purely GPU-side particle simulation with no seekable state and no time in its tile URLs — the honest move is not to force a frame but to mask the region, excluding those pixels from the comparison. Masking is the fallback, not the goal: it blinds the test to real regressions inside the masked box (a radar layer that renders solid black would pass), so it is reserved for the genuinely un-freezable and kept as tight as possible around the offending pixels. The region-masking machinery itself lives in Interactive Overlay Masking Rules; this guide only decides when to fall back to it.

Pinning one frame of an animated layer versus masking the region The top row shows an animated layer's timeline as six frames advancing left to right at time indices t0 through t5, with an animation clock arrow sweeping across them; in an unpinned test the shutter can land on any of the six, so the captured frame is non-deterministic. The middle row shows the pin strategy: the test forces the clock to a fixed index t2, so every run renders that one frame and the capture is deterministic. The bottom row shows the masking fallback used when the layer cannot be frozen: all frames still advance freely, but a mask rectangle covers the layer's region so those pixels are excluded from the diff and the rest of the map is still compared. ANIMATED LAYER — 6 FRAMES, CLOCK RUNNING t0 t1 t2 t3 t4 t5 clock shutter can land on any frame → non-deterministic capture PIN — CLOCK FORCED TO t2 t0 t1 t2 ✓ t3 t4 t5 every run renders t2 → deterministic capture FALLBACK — CANNOT FREEZE, MASK THE REGION masked region — excluded from diff rest of map still compared

Step-by-step: freezing an animated layer for capture

The procedure below assumes the camera is already locked and the basemap tiles are gated per Handling Async Tile Loading. It layers the freeze on top: classify the layer, apply the strongest freeze it supports, verify a still frame, then capture.

  1. Expose the layer and its player on window. You cannot pin what you cannot reach. In the app’s test build, publish the map and any animation controller so the harness can drive them. This is the single dependency the whole procedure rests on.

    // In the application's test entry point
    window.__testMap = map;
    window.__radarPlayer = radarPlayer;   // the frame-sequence controller
    window.__windLayer = windLayer;       // the particle layer instance
    
  2. Freeze the wall clock and RNG before the layer initializes. Frame-sequence players commonly pick a starting frame from Date.now(), and particle layers seed from Math.random(). Install deterministic stand-ins at document start — before any layer script runs — so both are fixed for the whole session.

    await page.addInitScript(() => {
      const FIXED = 1_752_460_800_000;      // 2026-07-14T00:00:00Z, pinned
      const RealDate = Date;
      // Freeze Date.now() and new Date() with no args to the fixed instant.
      window.Date = class extends RealDate {
        constructor(...args) { super(...(args.length ? args : [FIXED])); }
        static now() { return FIXED; }
      };
      let seed = 0x2545f491;                 // deterministic PRNG for particles
      Math.random = () => (((seed = (seed * 1103515245 + 12345) & 0x7fffffff)) / 0x7fffffff);
    });
    
  3. Step the frame-sequence player to a fixed index and pause it. For radar and satellite loops, seek to a known timestamp through the player’s own API and stop the timer, so the visible source is a specific frame rather than whatever the clock landed on.

    await page.evaluate(() => {
      const player = window.__radarPlayer;
      player.pause();                 // stop the interval that advances frames
      player.setFrame(2);             // or player.setTime('2026-07-14T00:00Z')
      player.stopCrossfade?.();       // hold a single source, not a blend of two
    });
    
  4. Halt the particle simulation on a fixed tick. Data-driven layers have no seekable frame, so freeze the loop itself: cancel the layer’s requestAnimationFrame and, if it advances by elapsed time, force a fixed delta so the last rendered state is reproducible.

    await page.evaluate(() => {
      const layer = window.__windLayer;
      layer.pause?.();                          // stop internal rAF if exposed
      if (layer._raf) cancelAnimationFrame(layer._raf);
      layer.setTime?.(0);                       // integrate to a fixed sim time
      window.__testMap.triggerRepaint();        // draw the frozen state once
    });
    
  5. Intercept temporal tile requests when the API is out of reach. If a layer exposes neither a seekable frame nor a pausable loop — a third-party or minified layer — pin it at the network. Rewrite the time segment of every tile URL to one fixed index so the layer always paints the same moment regardless of its internal clock.

    await context.route(/\/radar\/(\d+)\/(\d+\/\d+\/\d+)\.png/, (route) => {
      const url = route.request().url();
      const fixed = url.replace(/\/radar\/\d+\//, '/radar/1752460800/');
      route.continue({ url: fixed });
    });
    
  6. Confirm the frame is actually still, then capture. A paused layer can still have one repaint in flight. Assert two consecutive animation frames with no canvas mutation — the render-completion check from WebGL Idle & Render-Completion Detection — before the shutter, and capture with CSS animations disabled so any pulsing symbol is held too.

    await page.evaluate(() => new Promise((resolve) => {
      let still = 0;
      const tick = () => (++still >= 2 ? resolve() : requestAnimationFrame(tick));
      requestAnimationFrame(tick);
    }));
    const buffer = await page.locator('#map').screenshot({ animations: 'disabled' });
    

The fully wired version of this procedure — with a real radar player, a particle layer, and the fixture server — is worked through in Freezing animated weather and traffic tile layers for tests.

How this differs from generic animation suppression

It is tempting to treat all of this as a subset of “turn off the animations,” and Playwright’s animations: 'disabled' screenshot option plus a global * { animation: none !important; } stylesheet do exactly that for CSS timelines. That approach — the subject of Animation & Transition Suppression — is necessary but insufficient here, and conflating the two is a common source of “I disabled animations but the radar still flickers” bugs.

The difference is where the animation lives. CSS/WAAPI animation is a presentation-layer effect over stable content: freezing it holds the element at its current computed style and the underlying pixels are already deterministic. A frame-sequence radar loop or a particle field is a data or simulation animation — the content itself differs frame to frame, driven by JavaScript and the GPU, entirely outside the CSS animation timeline that animations: 'disabled' controls. No amount of CSS suppression stops a requestAnimationFrame loop that re-integrates particle positions or a setInterval that swaps the visible radar source. That is why this guide fixes the clock, seeks the frame, or intercepts the tile — mechanisms that reach the data layer — and treats CSS suppression only as the last step that mops up the decorative symbols on top. Use both, in that order.

Cross-browser and cross-environment considerations

An animated layer’s freeze is more environment-sensitive than a static capture, because it touches the render loop, the GPU simulation, and time itself.

  • Chromium is the reliable baseline. Its route interception (step 5) and init-script clock override (step 2) are stable, and --use-gl=angle --use-angle=swiftshader makes the particle layer’s GPU output deterministic across runners — essential, because a wind field rasterized on a host GPU versus SwiftShader diverges far more than a static basemap does.
  • WebKit (Playwright) lacks the GL software-backend flags, so a GPU particle layer can render subtly differently run to run even when frozen. If you must test on WebKit, prefer the tile-interception freeze (step 5) for frame-sequence layers and lean on masking for pure GPU simulations rather than trusting a pixel-exact frozen frame.
  • Firefox schedules requestAnimationFrame on a different cadence, so a particle layer that integrates by elapsed time reaches a different state at “the same” tick. Always integrate to a fixed simulation time (step 4), never to a frame count, so the frozen state is engine-independent.
  • Time zone and locale leak into frame selection: a radar player that picks “the latest frame” from a local timestamp will choose differently on a runner set to America/Los_Angeles than one on UTC. Pin timezoneId: 'UTC' on the browser context in addition to freezing Date, so both the clock value and its interpretation are fixed.
  • Containerization. Serve the timestamped tile sets from local fixtures so a real weather feed — which genuinely changes every few minutes — cannot leak a new frame into a run. A live upstream is the one variance source that no amount of client-side freezing can contain; the network-boundary controls it shares with Cache & CDN Invalidation Testing are what pin the data itself.

Freeze technique reference

Match the animation class to its strongest available freeze, with the fallback for when that freeze is out of reach. Prefer techniques higher in each cell.

Animation type Freeze technique Fallback when unavailable
Frame-sequence raster loop (radar, satellite, nowcast) Seek to a fixed frame via setFrame/setTime and pause the player; disable crossfade Intercept and rewrite the time segment of tile URLs to one fixed index
Data-driven particle / flow field (wind, currents) Freeze Math.random seed, integrate to a fixed simulation time, cancel the layer rAF Mask the layer region — a GPU sim with no seekable state cannot be pinned exactly
Traffic tint layer (periodic re-fetch) Pin the fetch clock and intercept the tile/data request to a fixed timestamp Mask the traffic-tinted road region
CSS-animated symbol (pulse, ripple, sweep hand) animations: 'disabled' on capture plus animation: none stylesheet Mask the symbol’s bounding box
Canvas-redraw symbol (per-frame custom draw) Stop the redraw loop and force one deterministic draw Mask the canvas element’s region
Cross-fade transition between two frames Stop the blend and hold a single source Intercept so both source URLs resolve to the same bytes

Common pitfalls

The radar layer is paused but the capture still differs every run

Root cause: the player was paused mid-crossfade, so the visible frame is a blend of two timestamped sources whose blend ratio depends on exactly when pause() landed. Diagnose: log the player’s active source IDs and blend factor at capture time; a non-zero, run-varying blend factor confirms it. Fix: call the player’s crossfade-disable control (or set fade duration to zero) and seek to a discrete frame index in step 3, so a single source is visible with no interpolation.

Freezing Date.now() had no effect on the particle layer

Root cause: the particle layer advances by performance.now() or the timestamp argument passed into its requestAnimationFrame callback, not by Date.now(), so overriding Date alone leaves the simulation clock running. Diagnose: search the layer source for performance.now and the rAF callback’s timestamp parameter. Fix: integrate the simulation to a fixed time via the layer’s own setTime (step 4) and cancel its rAF loop, rather than trying to freeze the ambient clock.

The gate never fires because idle never arrives

Root cause: the particle layer holds the render loop open by calling map.triggerRepaint() on every frame, so map.on('idle') never resolves and the capture times out. Diagnose: the run hangs at the idle wait with the basemap fully loaded and only the animated layer active. Fix: do not wait for idle on a map with a live animated layer; pause the layer first (steps 3–4), then use the two-frame no-repaint stillness check in step 6 as the capture signal instead.

Masking the whole layer hides a real regression

Root cause: the fallback mask was drawn around the entire map viewport because the animated layer covers it, so the diff now ignores the basemap, labels, and static overlays too. Diagnose: introduce a deliberate basemap change and confirm the test still passes — if it does, the mask is too broad. Fix: tighten the mask to the animated layer’s actual painted extent, and prefer a real freeze (steps 3–5) so nothing has to be masked at all. Masking is the last resort, not the default.

Frozen frame looks right locally but flickers in CI

Root cause: the timestamped tiles came from a live weather feed that served a newer frame to the CI run, or the GPU particle field rasterized differently on the runner’s GPU than on the local machine. Diagnose: re-run the same commit twice in CI and diff the two current captures; a non-empty diff is a determinism bug, not a regression. Fix: serve the tile sets from pinned local fixtures and force --use-gl=swiftshader so both the data and its rasterization are identical everywhere.

Frequently asked questions

Why does animations: 'disabled' not stop my weather radar loop?

Because a radar loop is a data animation, not a CSS one. animations: 'disabled' and animation: none only freeze CSS and Web Animations API timelines; they cannot stop a setInterval that swaps the visible tile source or a requestAnimationFrame loop that re-integrates a particle field. Pin the layer’s clock, seek its frame through the player API, or intercept the timestamped tile request instead, and use CSS suppression only for the decorative symbols layered on top.

How do I freeze a WebGL particle wind layer that has no frame index?

A particle layer has a clock and a seed rather than seekable frames. Freeze Math.random (and performance.now if it integrates by elapsed time) to fixed values before the layer initializes, integrate the simulation to a fixed time via its setTime if exposed, then cancel its requestAnimationFrame loop and trigger one repaint. If the layer exposes no such controls, mask its region — a pure GPU simulation with no seekable state cannot be pinned to an exact frame.

When should I mask an animated layer instead of freezing it?

Mask only when every freeze fails: no seekable frame, no pausable loop, no timestamp in the tile URLs, and a GPU simulation you cannot reach. Masking blinds the test to real regressions inside the masked box, so keep it as tight as possible around the layer’s painted extent and treat it as a last resort. A frozen frame still validates that the layer renders correctly; a mask only validates everything around it.

← Back to Dynamic Element Masking & UI Stability