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.
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.
Related
- Animation & Transition Suppression — the parent discipline covering every temporal rendering channel on a web map.
- Interactive Overlay Masking Rules — pair suppression with masking to isolate components without capturing transient hover or focus states.
- Marker Cluster Stability — freezing transitions lets cluster count and centroid assertions run without diffing repositioning frames.