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.
-
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 fromMath.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. -
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. -
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.
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.
-
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 -
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 fromMath.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); }); -
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 }); -
Halt the particle simulation on a fixed tick. Data-driven layers have no seekable frame, so freeze the loop itself: cancel the layer’s
requestAnimationFrameand, 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 }); -
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 }); }); -
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=swiftshadermakes 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
requestAnimationFrameon 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_Angelesthan one onUTC. PintimezoneId: 'UTC'on the browser context in addition to freezingDate, 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.
Related
- Up to the parent guide Dynamic Element Masking & UI Stability — turning a volatile map canvas into a reproducible one.
- Freezing animated weather and traffic tile layers for tests — the fully wired version of this freeze procedure.
- Animation & Transition Suppression — the CSS-timeline suppression that this guide layers on top of a data-layer freeze.
- Interactive Overlay Masking Rules — the region-masking machinery used as the fallback here.
- Cache & CDN Invalidation Testing — pinning the tile data itself at the network boundary.
- WebGL Idle & Render-Completion Detection — the stillness signal that tells you a frozen frame has finished painting.
← Back to Dynamic Element Masking & UI Stability