Animation & Transition Suppression

Uncontrolled animations and CSS transitions are the primary vector for non-deterministic pixel diffs in map visual regression. Geospatial interfaces inherently depend on temporal rendering behaviours — panning inertia, zoom interpolation, marker clustering transitions, and overlay fade-ins — which introduce frame-to-frame variance during automated screenshot capture. Systematic suppression of these temporal effects produces a synchronous, predictable rendering state during test execution without degrading production UX, so that a pixel difference signals a genuine cartographic regression rather than a frame caught mid-ease.

This page extends the masking and stabilisation concepts in Dynamic Element Masking & UI Stability: that parent discipline neutralises spatial variance (what is visible), while animation suppression neutralises temporal variance (when it settles). The two are complementary — a perfectly masked overlay still produces a flaky diff if it slides into place over 200 ms, and a frozen frame still fails if a tooltip happens to be open. Suppression is also tightly coupled to screenshot capture synchronization, which owns the question of when the harness fires the capture once motion has stopped.

What Counts as a Suppressible Animation

“Animation” on a web map spans four distinct rendering channels, each of which settles on a different timeline and resists a different control surface:

  • Declarative CSS transitionstransition on opacity, transform, or color, fired by class changes (a panel sliding open, a button hover). Governed by the CSS Transitions model: an interruptible interpolation between two computed style values.
  • CSS keyframe animations@keyframes loops such as a pulsing “live” marker or an indeterminate spinner. These can run indefinitely (animation-iteration-count: infinite), so they never reach a stable end state on their own.
  • JavaScript-driven DOM animationElement.animate() via the Web Animations API, or library timelines (GSAP, Framer Motion) that mutate inline styles each frame.
  • GPU render-loop animation — the one that breaks naïve suppression. WebGL mapping engines such as MapLibre GL, Mapbox GL, and Deck.gl bypass the DOM compositor entirely and drive their own requestAnimationFrame loop for camera easing, tile fade-in, and symbol collision. No stylesheet touches this channel.

Suppression is the act of collapsing all four channels to their terminal frame before capture, while leaving production code paths untouched. The first three respond to declarative overrides; the fourth requires either a synchronous requestAnimationFrame shim or a library-native idle signal. Getting this taxonomy right is what separates a flake rate of 0.5% from one of 15%.

A useful invariant: a frame is capturable only when every channel reports no work pending. For MapLibre GL the canonical signal is the idle event, which fires only when all tiles are loaded, all transitions have completed, and the render queue is empty — the GPU-channel equivalent of document.readyState === 'complete'.

The Three-Layer Suppression Architecture

The suppression architecture operates across three layers that map onto the channels above: declarative stylesheet injection, JavaScript runtime patching of the render scheduler, and test-harness orchestration that sequences the two against the runner lifecycle. No single layer is sufficient — stylesheet suppression alone cannot reach the WebGL loop, and a requestAnimationFrame shim alone leaves DOM transitions free to fire on the next class change.

The three-layer suppression architecture Three independent suppression layers — CSS injection reaching the DOM compositor, a JavaScript runtime patch reaching the GPU render loop, and test-harness orchestration sequencing them against the runner — all converge on a single deterministic, flicker-free capture state. No single layer is sufficient alone. Layer 1 — CSS injection zero-duration transitions & keyframes reaches the DOM compositor only Layer 2 — JS runtime patch synchronous requestAnimationFrame reaches the GPU render loop · await idle Layer 3 — Harness orchestration inject → interact → await idle → capture sequences layers against the runner Deterministic, flicker-free capture state IDEMPOTENT · BYTE-IDENTICAL

The design goal is idempotent determinism: running the same suite twice on the same commit must yield byte-identical captures. Each layer is independently versioned and injected before the rendering surface it governs comes alive — CSS before DOM hydration, the render-loop patch before map construction, the orchestration gate around the capture call. This ordering is the load-bearing detail; injecting the requestAnimationFrame shim after the map has already cached a reference to the native function is the single most common reason suppression silently fails.

Layer 1 — CSS Injection & Declarative Override

The first defence against temporal variance is a globally scoped, test-specific stylesheet injected before test navigation. Target both native CSS transitions and keyframe animations, including pseudo-elements and dynamically injected overlay containers:

/* test-suppression.css */
.test-mode *,
.test-mode *::before,
.test-mode *::after {
  transition-duration: 0ms !important;
  transition-delay: 0ms !important;
  animation-duration: 0ms !important;
  animation-delay: 0ms !important;
  animation-iteration-count: 1 !important;
  animation-fill-mode: forwards !important;
  scroll-behavior: auto !important;
  will-change: auto !important;
}

This guarantees that DOM-managed UI components — loading spinners, tooltip fade-ins, and panel slide-outs — render in their final state immediately. animation-fill-mode: forwards is essential: a zero-duration animation still needs forwards to hold its terminal frame rather than snapping back to the unanimated base style. Collapsing durations also prevents the layout thrashing that concurrent transition calculations cause, which frequently corrupts bounding-box measurements during pixel-diff analysis.

A production-safe alternative to a bespoke .test-mode class is to honour the prefers-reduced-motion media query and drive it from the runner (page.emulateMedia({ reducedMotion: 'reduce' })). This only works if the application already gates its animations behind that query — a good accessibility practice, but not a guarantee, so most suites pair both. The standalone procedure for the CSS layer is documented in Disabling CSS animations for consistent visual baselines.

Layer 2 — JavaScript Runtime Patching & WebGL Synchronisation

WebGL mapping engines operate on independent render loops that ignore CSS overrides. Libraries like MapLibre GL and Deck.gl continuously call requestAnimationFrame to redraw frames during camera movement, tile loading, or vector styling updates. A robust patch replaces window.requestAnimationFrame with a synchronous executor that immediately invokes the callback, collapsing the animation loop into a single synchronous pass:

// raf-sync-patch.js — install BEFORE any map is constructed
(function () {
  if (window.__MAP_TEST_SYNC__) return;
  window.__MAP_TEST_SYNC__ = true;

  const originalRAF = window.requestAnimationFrame;
  window.requestAnimationFrame = function (callback) {
    callback(performance.now());
    return 0; // dummy id so cancelAnimationFrame never throws
  };

  // Restore the native loop on teardown so other tabs/tests are unaffected.
  window.__restoreRAF__ = () => {
    window.requestAnimationFrame = originalRAF;
    window.__MAP_TEST_SYNC__ = false;
  };
})();

The synchronous shim is a blunt instrument: it forces every queued frame to run inline, which collapses camera easing to its end state but can also starve libraries that use requestAnimationFrame for throttling. For production-grade pipelines, prefer library-native synchronisation over the global override wherever the engine exposes one. MapLibre GL’s idle event is the cleanest signal; pair it with map.areTilesLoaded() and per-source isSourceLoaded() to defeat the race where idle fires between two tile batches. Disabling easing at the call site is even more reliable than freezing the loop — map.jumpTo() instead of map.flyTo(), or { duration: 0 } on any camera operation — because it removes the animation rather than racing to capture its end.

Layer 3 — Test Harness Orchestration & Event Gating

Suppression is only effective when tightly coupled to the runner’s lifecycle. The orchestration layer sequences the previous two against navigation, interaction, and capture so that no frame is ever sampled while work is pending.

Harness capture sequence with the idle gate A left-to-right sequence: inject CSS, patch requestAnimationFrame, navigate, interact with pan or zoom, then await the idle event plus areTilesLoaded — the gate — before capturing the screenshot. Firing the capture before the gate resolves samples a mid-frame and produces a flaky pixel diff. 1 Inject CSS at doc start 2 Patch rAF before map init 3 Navigate page.goto 4 Interact pan · zoom · toggle GATE Await idle + areTilesLoaded 6 Capture screenshot Skipping the gate captures a mid-frame → flaky pixel diff

The harness must, in order:

  1. Inject the CSS override before DOM hydration (addInitScript / addStyleTag at document start).
  2. Apply the requestAnimationFrame patch before map initialisation, so the engine never caches the native function.
  3. Trigger the target interaction — pan, zoom, layer toggle, or filter.
  4. Await the engine’s idle / rendercomplete signal and a tile-loaded assertion, under a strict timeout.
  5. Capture the screenshot immediately after the gate resolves, never on a fixed sleep.

This sequence prevents the race conditions where the runner captures mid-frame, especially during high-DPI rendering or WebGL shader compilation. A fixed waitForTimeout(500) is the canonical anti-pattern: it is simultaneously too slow on fast runners and too fast on a cold CI node compiling shaders, so it trades one flake for two.

Step-by-Step Implementation

The following sequence wires all three layers into a runner. It assumes the application exposes the map instance globally (window.mapInstance) for test introspection — a common test-only hook.

  1. Expose an idle predicate. Have the app attach a boolean the harness can poll, derived from the engine’s own signals:

    // app-test-hooks.js (loaded only under test)
    map.on('load', () => {
      window.__mapIdle = false;
      map.on('idle', () => { window.__mapIdle = map.areTilesLoaded(); });
      map.on('movestart', () => { window.__mapIdle = false; });
    });
    
  2. Install suppression before navigation (Playwright). addInitScript runs in every frame before any page script, guaranteeing the patch wins the race against map construction:

    test.beforeEach(async ({ page }) => {
      await page.addInitScript(() => {
        window.requestAnimationFrame = (cb) => { cb(performance.now()); return 0; };
      });
      await page.addStyleTag({ content: `
        *, *::before, *::after {
          transition-duration: 0ms !important;
          animation-duration: 0ms !important;
          animation-iteration-count: 1 !important;
          animation-fill-mode: forwards !important;
        }
      `});
      await page.emulateMedia({ reducedMotion: 'reduce' });
    });
    
  3. Drive the interaction, then gate the capture (Playwright):

    test('map visual baseline', async ({ page }) => {
      await page.goto('/gis-dashboard');
      await page.waitForFunction(() => window.__mapIdle === true);
    
      await page.click('[data-testid="zoom-in"]');
      await page.waitForFunction(() => window.__mapIdle === true, null, { timeout: 10000 });
    
      await expect(page).toHaveScreenshot('map-baseline.png', {
        maxDiffPixels: 0,
        animations: 'disabled',
        clip: { x: 0, y: 0, width: 1280, height: 720 },
      });
    });
    

    Playwright’s own animations: 'disabled' option finishes any in-flight CSS animation/transition at capture time, layering a fourth safety net over the injected stylesheet.

  4. Mirror the gate in Cypress for suites built on that runner:

    Cypress.Commands.add('waitForMapIdle', (timeout = 10000) => {
      cy.window({ timeout }).should((win) => {
        expect(win.__mapIdle, 'map idle').to.equal(true);
      });
    });
    
    it('captures a stable map state', () => {
      cy.visit('/gis-dashboard');
      cy.waitForMapIdle();
      cy.get('[data-testid="zoom-in"]').click();
      cy.waitForMapIdle();
      cy.get('[data-testid="map-container"]').matchImageSnapshot();
    });
    
  5. Restore native behaviour on teardown so a long-lived browser context does not leak the synchronous shim into unrelated specs (page.evaluate(() => window.__restoreRAF__?.())).

Once the frame is provably stable, the comparison itself is owned by diff algorithm tuning for cartography, and the approved images by baseline management for tile servers.

Cross-Browser & Cross-Environment Considerations

Browser engines implement fundamentally different compositing pipelines. Chromium uses Skia with GPU-accelerated rasterisation, Firefox relies on WebRender, and WebKit employs CoreGraphics with distinct paint scheduling. These differences manifest as sub-pixel anti-aliasing variations, font-rendering discrepancies, and divergent WebGL precision defaults — all of which survive perfect animation suppression and must be locked separately.

To enforce deterministic rendering across CI nodes, standardise the headless browser launch flags:

Flag Purpose
--disable-gpu Forces software rasterisation to eliminate GPU driver variance between runners
--use-gl=swiftshader Pins a software WebGL backend so shader output is identical across machines
--force-device-scale-factor=1 Locks DPR to 1.0, preventing fractional scaling artifacts
--no-sandbox Required for most containerised CI runners
--font-render-hinting=none Eliminates OS-level font-smoothing differences
--force-color-profile=srgb Normalises colour management so identical pixels hash identically

Three environment notes compound with the flags. First, viewport dimensions must be integers (e.g. 1280 × 720); fractional sizes trigger sub-pixel rounding that propagates to tile-boundary seams and vector strokes. Second, fonts must be bundled in the container image — a label that renders in a fallback font on the runner but the bundled font locally produces a full-width diff that looks like an animation flake but is not. Third, the synchronous requestAnimationFrame shim behaves differently under WebKit’s event loop than under Chromium’s; on WebKit prefer the native idle gate over the global override, since the shim can deadlock libraries that await a real frame. WebKit also ignores several Chromium-only flags, so suppression there leans harder on Layers 1 and 3. These engine divergences are the same ones that drive the broader screenshot capture synchronization strategy.

Threshold & Parameter Reference

Suppression introduces a small set of tunable values. The defaults below are conservative starting points for a MapLibre GL dashboard on containerised CI; widen timeouts for cold runners and tighten pixel tolerance only once the frame is provably stable.

Parameter Recommended value Rationale
transition-duration / animation-duration (injected) 0ms !important Collapses every declarative animation to its terminal frame
animation-iteration-count (injected) 1 !important Stops infinite loops (pulsing markers, spinners) from never settling
Idle gate timeout 10000 ms Generous enough for cold-node shader compilation; fails fast on a genuine hang
Camera operation duration 0 (jumpTo / { duration: 0 }) Removes easing at the source rather than racing to capture its end
deviceScaleFactor 1 Avoids backing-store scaling that shifts sub-pixel anti-aliasing
maxDiffPixels 0 once stable, else per-zoom tolerance Zero only after suppression + font/GL pinning; otherwise tune per zoom
Tile-loaded assertion areTilesLoaded() === true at idle Defeats the idle-fires-between-batches race

For per-zoom pixel and structural tolerance — where anti-aliasing on dense labels forces a non-zero floor — calibrate against the matrix in dynamic threshold configuration rather than guessing a global value.

Common Pitfalls

Patch installed too late. The most frequent failure: the requestAnimationFrame shim is injected after page.goto, but the map library already captured window.requestAnimationFrame into a private variable during module evaluation. Fix: install via addInitScript (Playwright) or cy.visit({ onBeforeLoad }) (Cypress) so the patch runs before any page script.

Infinite keyframe animation captured mid-cycle. A pulsing “live data” marker with animation-iteration-count: infinite keeps animating even with a zero duration if the override forgets the count. Fix: always pair animation-duration: 0ms with animation-iteration-count: 1 and animation-fill-mode: forwards.

idle fires before tiles finish. On MapLibre GL the idle event can fire in the gap between two tile-fetch batches, so the harness captures a half-loaded grid. Fix: gate on areTilesLoaded() and per-source isSourceLoaded() in addition to idle, and serve tiles from local fixtures, as covered in handling async tile loading.

Camera easing raced instead of removed. Capturing the end of a flyTo works most of the time and fails the rest, producing a one-or-two-pixel geometry shift. Fix: replace flyTo with jumpTo (or pass { duration: 0 }) and gate on isMoving() / isRotating(); align this with your viewport & zoom sync strategies.

Synchronous shim deadlocks WebKit. The global override that works under Chromium can hang a WebKit run when a library awaits a real frame that now never yields. Fix: on WebKit drop the global shim and rely on the native idle gate plus the injected stylesheet.

Overlay re-injects after suppression. A tooltip or context menu re-renders during the diff phase because suppression ran before the overlay mounted. Fix: run masking after the idle event and coordinate with interactive overlay masking rules.

Frequently Asked Questions

Why do my map screenshots still differ even with CSS animations disabled?

Because the visible variance is coming from the WebGL render loop, not the DOM. CSS overrides cannot reach a MapLibre GL or Deck.gl requestAnimationFrame loop. Add Layer 2 (a synchronous requestAnimationFrame shim or, better, gate on the library’s native idle event plus areTilesLoaded()) and replace eased camera moves with jumpTo.

Should I freeze requestAnimationFrame globally or use the map library's idle event?

Prefer the library’s idle event for production pipelines — it is precise and side-effect free. The global synchronous shim is a useful fallback when no idle signal exists, but it can starve throttled code and deadlock WebKit. Many suites install the shim and gate on idle for defence in depth.

How long should the idle-gate timeout be?

Start at 10 seconds. That absorbs cold-node WebGL shader compilation while still failing fast on a genuine hang. Tune downward only after the suite is stable; a fixed sleep is never an acceptable substitute because it is too slow on fast runners and too fast on cold ones.

Does disabling animations change what users see in production?

No, when done correctly. Suppression is scoped to the test session — a .test-mode class, an injected stylesheet, prefers-reduced-motion, or runner-only init scripts — and is removed on teardown. Production code paths and the deployed bundle are untouched.

← Back to Dynamic Element Masking & UI Stability