Disabling CSS Animations for Consistent Visual Baselines

You have a map screenshot test that passes nine runs out of ten, and the tenth fails on a diff that shows motion blur along a panel edge or a marker caught mid-scale. The cause is almost always a CSS animation or transition still interpolating when the shutter fired. This page gives the exact override, the per-library API calls that the override cannot reach, and a verification step that proves suppression took effect before you trust a single baseline.

Where this fits

This is a focused procedure within Animation & Transition Suppression, the guide that catalogues every temporal rendering channel on a web map and how to freeze each one. It sits under the broader discipline of Dynamic Element Masking & UI Stability, which neutralises spatial variance while suppression here neutralises the temporal variance that produces flaky pixel diffs.

Prerequisites

Step-by-step procedure

1. Author the cascade-dominant override

The most reliable suppression operates at the stylesheet cascade level, injected before test navigation. A global override using !important guarantees dominance over library defaults and inline style mutations:

*, *::before, *::after {
  animation-duration: 0.001ms !important;
  animation-iteration-count: 1 !important;
  transition-duration: 0.001ms !important;
  transition-delay: 0ms !important;
  animation-play-state: paused !important;
}

2. Use 0.001ms, not 0ms

Setting durations to 0.001ms rather than 0ms is a deliberate engineering distinction. Some browser engines interpret zero-duration animations as skipped lifecycle events, which can trigger race conditions in WebGL context initialization or defer DOM layout calculations. As documented in the W3C CSS Animations Level 1 specification, a non-zero duration ensures the animation frame is processed synchronously while collapsing the visual delta to a single tick. The compositor applies final computed styles immediately and the easing curve is bypassed entirely.

3. Inject the override before the render loop starts

The stylesheet must be present before the mapping application initializes its render loop, otherwise the first frame still animates. Each harness has a different injection hook:

Playwright

test.beforeEach(async ({ page }) => {
  await page.addStyleTag({
    content: `
      *, *::before, *::after {
        animation-duration: 0.001ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.001ms !important;
        transition-delay: 0ms !important;
        animation-play-state: paused !important;
      }
    `
  });
  await page.goto('/map-dashboard');
});

Cypress

beforeEach(() => {
  cy.on('window:before:load', (win) => {
    const style = win.document.createElement('style');
    style.textContent = `
      *, *::before, *::after {
        animation-duration: 0.001ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.001ms !important;
        transition-delay: 0ms !important;
        animation-play-state: paused !important;
      }
    `;
    win.document.head.appendChild(style);
  });
  cy.visit('/map-dashboard');
});

Puppeteer

await page.evaluateOnNewDocument(() => {
  const style = document.createElement('style');
  style.textContent = `
    *, *::before, *::after {
      animation-duration: 0.001ms !important;
      animation-iteration-count: 1 !important;
      transition-duration: 0.001ms !important;
      transition-delay: 0ms !important;
      animation-play-state: paused !important;
    }
  `;
  document.head.appendChild(style);
});
await page.goto('/map-dashboard');

Each pattern executes before any application JavaScript evaluates, guaranteeing cascade dominance from the first paint.

4. Suppress the canvas and WebGL channels the CSS cannot reach

CSS overrides only target DOM elements. WebGL canvases and canvas-based vector renderers bypass the CSS compositor, so they need library-specific API calls to collapse their internal animation loops:

Library DOM suppression WebGL / Canvas suppression
MapLibre GL / Mapbox GL CSS override above Use map.jumpTo() instead of map.easeTo(); await map.once('idle', cb)
Leaflet CSS override above map.setView(coords, zoom, { animate: false })
OpenLayers CSS override above view.animate() with duration: 0; disable kinetic pan with new ol.interaction.DragPan({ kinetic: false })
ArcGIS JS API CSS override above view.goTo(target, { animate: false })

5. Combine both layers for hybrid interfaces

A dashboard that mixes a WebGL basemap with DOM overlays needs the CSS override and the programmatic jump. In Playwright:

await page.evaluate(() => {
  // Force MapLibre to jump without easing (setAnimationEnabled is not a MapLibre API)
  if (window.map && typeof window.map.jumpTo === 'function') {
    window.map.jumpTo({ center: window.map.getCenter(), zoom: window.map.getZoom() });
  }
  // Force Leaflet to skip pan transitions
  if (window.leafletMap && typeof window.leafletMap.setView === 'function') {
    window.leafletMap.setView([40.7128, -74.0060], 12, { animate: false });
  }
});

The MDN reference on requestAnimationFrame explains why the GPU render loop schedules frames independently of the DOM compositor, and therefore why these explicit API overrides are mandatory for canvas-heavy GIS stacks.

Two-layer animation suppression converging on one capturable frame Two parallel channels are suppressed independently. On the DOM channel, a cascade-dominant CSS override injected before navigation feeds the CSS compositor, which applies final computed styles immediately. On the GPU and canvas channel, an engine camera API call such as jumpTo or animate false collapses the requestAnimationFrame loop that schedules GPU frames; a dashed link marked "CSS cannot reach" shows why the override alone is insufficient for WebGL renderers. Both channels converge on a single deterministic frame with no interpolation, which is then captured against a zero-percent structural diff gate. DOM CHANNEL GPU / CANVAS CHANNEL CSS cascade override *,*::before,*::after · 0.001ms !important injected before navigation CSS compositor applies final computed styles Engine camera API jumpTo · animate:false · duration:0 called before capture requestAnimationFrame loop schedules GPU frames CSS cannot reach Single deterministic frame no interpolation · easing bypassed Capture · 0.0% structural diff gate

Verification

Add a pre-snapshot check that queries computed styles to confirm the override applied before you trust any baseline:

async function verifyAnimationSuppression(page) {
  const styles = await page.evaluate(() => {
    const cs = window.getComputedStyle(document.body);
    return {
      animationDuration: cs.animationDuration,
      transitionDuration: cs.transitionDuration,
      animationPlayState: cs.animationPlayState
    };
  });

  if (styles.animationDuration !== '0.001ms' || styles.transitionDuration !== '0.001ms') {
    throw new Error('CSS animation suppression failed. Check cascade specificity or injection timing.');
  }
}

A correct run reports animationDuration: '0.001ms', transitionDuration: '0.001ms', and animationPlayState: 'paused'. In CI, set the structural diff threshold to 0.0% and the anti-aliasing tolerance to 0.1%; a green gate with those thresholds confirms no interpolation frame leaked into the capture. Use --retries=1 for flaky network-dependent tile loads, but never retry animation-related failures — those indicate a suppression misconfiguration, not environmental variance. Suppression also trims 40–60 ms of capture latency per test by removing the wait for easing curves to complete.

Troubleshooting

Symptom Likely cause Fix
Diff shows motion blur on DOM panels Override injected after the render loop started Move injection to beforeEach / evaluateOnNewDocument so it lands before goto
Marker still caught mid-scale on a WebGL basemap CSS override does not reach the GPU channel Replace easeTo/flyTo with jumpTo and await the engine idle event
Suppression check passes but tiles render at fractional coordinates Camera moved via an animated method Set { animate: false } (Leaflet/ArcGIS) or duration: 0 (OpenLayers) on every view change

Frequently asked questions

Why 0.001ms instead of just setting transitions to none?

Setting transition: none removes the property but can leave keyframe animations running, and some engines treat a zero or absent duration as a skipped lifecycle event that races WebGL context setup. A tiny non-zero duration forces the frame to be processed synchronously and collapses the visual delta to one tick, which is the most portable behaviour across Chromium, WebKit, and Firefox.

Does the override affect the production build?

No. The stylesheet is injected only inside the test context — through addStyleTag, window:before:load, or evaluateOnNewDocument — so production code paths and the shipped UX are untouched. The override exists for the lifetime of the test page and is discarded with it.

My WebGL map still flickers after applying everything here. What now?

The remaining variance is usually tile hydration rather than animation. Gate the capture on the engine idle signal and full tile load as described in screenshot capture synchronization, and if residual rendering noise survives, apply the preprocessing filters in Noise Reduction for Map Artifacts.