Root cause #
Automation drivers click at coordinates. A modern runner will wait for an element to be visible, enabled and stable before dispatching, but “stable” is judged over a short window — if the element’s box stops changing for a couple of frames, it counts as settled. A slow transition, a spring animation that overshoots and settles back, or an entrance effect that pauses mid-way can satisfy that window while still moving, and the click is then dispatched against a position the element has already left. The event goes to whatever is under the cursor: the backdrop, the element behind, or nothing at all.
The second mechanism is that animation interacts with visibility in non-obvious ways. An element animating from opacity: 0 is present, laid out and hit-testable before it is perceptible, so a test that waits for visibility can act on something the user cannot yet see — and an element animating out remains clickable for the length of its exit transition, which is how a test manages to click a button on a dialog that is closing.
The third is that disabling animation is easy to do incompletely. Setting a duration to zero on * misses pseudo-elements, animations declared with !important, scroll-behavior: smooth, and the Web Animations API, which is JavaScript-driven and unaffected by CSS at all. A partial disable is worse than none, because it removes the obvious motion and leaves the subtle motion that nobody thinks to check.
Step-by-step fix #
1. Inject a stylesheet that covers pseudo-elements and scrolling #
The reliable CSS switch is a stylesheet added before the app paints, applying to elements and their pseudo-elements with !important.
/* Trade-off: !important is normally a smell, and here it is the point — it must
beat author styles that also use !important, or the disable is partial. */
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
animation-iteration-count: 1 !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
}
html { scroll-behavior: auto !important; }
Note animation-iteration-count: an infinite spinner with a zero duration still iterates forever unless the count is capped, and some runners treat a perpetually animating element as never stable.
2. Apply it before the first paint, for every navigation #
Adding the stylesheet after goto() leaves the entrance animations of the initial render un-disabled — exactly the ones that cause the most trouble.
// Trade-off: an init script runs on every navigation in the test, which is what
// makes it reliable; it also means the style is re-applied on redirects.
test.beforeEach(async ({ context }) => {
await context.addInitScript(() => {
const style = document.createElement('style');
style.textContent = `*,*::before,*::after{animation-duration:0s!important;
animation-delay:0s!important;animation-iteration-count:1!important;
transition-duration:0s!important;transition-delay:0s!important}
html{scroll-behavior:auto!important}`;
document.documentElement.appendChild(style);
});
});
Cypress has a built-in equivalent for scrolling and supports the same stylesheet approach through a support file:
// cypress/support/e2e.js
// Trade-off: the built-in setting only covers Cypress's own scrolling, so the
// stylesheet is still needed for the application's animations.
Cypress.config('scrollBehavior', false);
beforeEach(() => {
cy.document().then((doc) => {
const style = doc.createElement('style');
style.innerHTML = '*,*::before,*::after{animation-duration:0s!important;transition-duration:0s!important}';
doc.head.appendChild(style);
});
});
3. Stop the animations CSS cannot reach #
Animations created through the Web Animations API ignore CSS duration overrides. Finish them explicitly, and cap any requestAnimationFrame loop the application drives itself.
// Trade-off: finishing animations forces them to their end state, which is
// what tests want and is not what a user would see mid-interaction.
await page.addInitScript(() => {
const finishAll = () => document.getAnimations?.().forEach((a) => a.finish());
document.addEventListener('DOMContentLoaded', finishAll);
new MutationObserver(finishAll).observe(document.documentElement, {
childList: true, subtree: true,
});
});
The observer keeps newly added animations from starting up after the initial pass — the same technique, and the same caveats about observer timing, as in Stabilizing MutationObserver Timing in E2E Tests.
4. Use the reduced-motion preference as the primary switch #
If the application already respects prefers-reduced-motion, the cleanest disable is to request it — the app then takes its own no-motion path, which is both realistic and something users actually experience.
// Trade-off: this only works to the extent the app honours the preference;
// where it does, it is far better than fighting the styles from outside.
export default defineConfig({
use: { reducedMotion: 'reduce' },
});
This has a pleasant side effect: running the functional suite under reduced motion means the reduced-motion code path gets continuous coverage, which is usually the least-tested branch in a design system.
5. Keep one project that runs with motion enabled #
Disabling animation everywhere means an animation bug — a transition that never completes, an element that ends in the wrong position — can ship unnoticed. Keep a small, tagged project that runs with motion on.
// Trade-off: a motion project is inherently slower and slightly less stable;
// scope it to a handful of specs so that instability is bounded.
projects: [
{ name: 'functional', use: { reducedMotion: 'reduce' } },
{ name: 'motion', use: { reducedMotion: 'no-preference' }, grep: /@motion/ },
],
6. Prove the disable actually took effect #
A silent regression in the disabling setup produces a slow drip of flaky clicks weeks later. Assert it once, cheaply.
// Trade-off: one extra test, and it is the only thing standing between a
// broken init script and a month of mysterious click failures.
test('animations are disabled in this project', async ({ page }) => {
await page.goto('/');
const duration = await page.evaluate(() => {
const el = document.querySelector('[data-animated]') ?? document.body;
return getComputedStyle(el).transitionDuration;
});
expect(duration).toBe('0s');
});
Pitfalls #
- Injecting the stylesheet after navigation. The initial entrance animations already ran. Mitigation: add it as an init script so it applies before page scripts.
- Omitting
!important. Author styles with their own!importantwin and the disable is partial. Mitigation: use it deliberately here. - Forgetting
animation-iteration-count. An infinite spinner at zero duration can still be treated as perpetually animating. Mitigation: cap the count at 1. - Assuming CSS covers everything. Web Animations,
requestAnimationFrameloops, canvas and video are untouched. Mitigation: finish animations through the API and observe for new ones. - Disabling motion for the whole suite with no exception. Animation regressions become invisible. Mitigation: keep one tagged project with motion enabled.
- Relying on the runner’s stability heuristic instead. It settles for a short window, which a slow or overshooting animation can satisfy while still moving. Mitigation: remove the motion rather than tuning the heuristic.
Reliability targets #
| Metric | Target | Notes |
|---|---|---|
| Click failures attributed to motion | 0 per month | Signature: click landed on the element behind |
| Specs running with motion disabled | 100% of functional project | Verified by the assertion test |
| Specs covering animation deliberately | 3–10, tagged | Enough for entrance, exit and loading states |
| Suite time saved by disabling motion | 5–15% | Transitions no longer waited out |
| Reduced-motion path coverage | Continuous | Free side effect of the primary switch |
Frequently Asked Questions #
Q: Does disabling animation make the tests less realistic? A: Slightly, and the trade is worth making for functional coverage. A user’s experience of a 200 ms fade is not what a functional test verifies — it verifies that clicking a row opens the right dialog. Keep realism where it is the subject, in a small tagged project, and take determinism everywhere else.
Q: Why does the element still move even though durations are zero?
A: Something outside CSS is driving it. The usual suspects are the Web Animations API, a requestAnimationFrame loop implementing a spring or parallax effect, or a third-party widget with its own animation engine. Finish the document’s animations through the API and check whether the motion is being computed in JavaScript.
Q: Should I use prefers-reduced-motion or force the stylesheet?
A: Prefer the media feature when the application honours it, because the app then follows its own no-motion path rather than being overridden from outside — fewer surprises, and it exercises a real user setting. Fall back to the injected stylesheet where the application ignores the preference, and treat that as a gap worth fixing in the product.
Q: Does disabling animation change what screenshots look like? A: Yes, and usually for the better: with motion off, a screenshot captures the settled state rather than an arbitrary frame, which is what makes visual comparison stable in the first place. The caveat is that any baseline captured with motion on will not match, so regenerate baselines in the same configuration the suite runs in.
Q: Our design system uses !important on transitions. Can I still disable them?
A: Yes — an author stylesheet later in the cascade with equally important declarations wins, which is why the injected rules use !important and are appended to the document element. If a specific component still animates, target it directly rather than weakening the global rule.