Disabling CSS animations before visual snapshots
This page is part of the dynamic content masking section of the visual regression guide, which covers making a capture deterministic before it is compared.
The specific problem: a toast fades in over 200 ms. Whether the screenshot catches it at 40% opacity or fully opaque depends on how busy the machine is, so the same commit produces a different image every run.
Problem statement
Motion is not random — it is untimed. A transition has a defined duration and a defined end state, and a capture taken at an arbitrary point during it records an arbitrary frame.
The symptom is a story whose diff percentage varies between runs with no code change, often by an order of magnitude, and which passes on a quiet machine and fails on a loaded one.
Root cause
A screenshot is taken when the automation asks for it, and the browser has no obligation to have finished animating by then. Adding a delay before the capture makes the race less likely to be lost without removing it, which is the same failure mode as a fixed sleep in a component test.
The right fix is to remove the duration. An animation with zero length has no intermediate frames, so there is nothing to race — and the frame captured is the settled state, which is what the snapshot was meant to record anyway.
Minimal reproduction
/* Toast.css */
.toast { animation: slide-in 200ms ease-out; }
@keyframes slide-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
// captures whatever frame happens to be on screen
await expect(page.locator('#storybook-root')).toHaveScreenshot('toast-success.png');
Step-by-step fix
1. Use Playwright’s animations option
await expect(page.locator('#storybook-root')).toHaveScreenshot('toast-success.png', {
animations: 'disabled',
});
What this does: fast-forwards CSS animations and transitions to their final state and cancels infinite ones, then captures. This covers the majority of motion in a component library.
2. Add a zero-duration stylesheet for the rest
// .storybook/test-runner.ts or a Playwright fixture
await page.addStyleTag({
content: `
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
scroll-behavior: auto !important;
}
`,
});
What this does: catches motion applied after the option is evaluated, and removes smooth scrolling, which otherwise moves the viewport mid-capture.
3. Emulate reduced motion as well
await page.emulateMedia({ reducedMotion: 'reduce' });
What this does: components that respect prefers-reduced-motion take their own no-animation path, which is both more deterministic and closer to what those users see — so the baseline documents a code path that otherwise goes uncaptured.
4. Pause JavaScript-driven motion explicitly
// a requestAnimationFrame loop is not CSS, so Playwright cannot finish it
await page.addInitScript(() => {
(window as any).__pauseAnimations = true; // the component checks this flag
});
What this does: gives components with imperative animation a documented way to stop, rather than resorting to a mask over the whole region.
5. Capture a moving component at a chosen frame
await page.clock.setFixedTime(new Date('2026-01-15T09:30:00Z'));
await page.goto('/iframe.html?id=carousel--autoplay');
await page.clock.runFor(3000); // advance to slide two, deliberately
await expect(page.locator('#storybook-root')).toHaveScreenshot('carousel-slide-2.png');
What this does: keeps an inherently animated component under comparison by choosing which frame is the subject.
Verification
Repeat the run three times against an unchanged commit. Identical images mean motion is genuinely gone; a residual fraction of a percent usually means one element is animating through a mechanism the stylesheet did not reach.
Edge cases and caveats
-
Animations that change layout. A slide-in that translates an element affects what is behind it. Disabling motion captures the final layout, which is correct — but a baseline captured before the fix may show the mid-transition layout and will need regenerating.
-
Infinite spinners.
animations: 'disabled'cancels them, which typically leaves the spinner at its first frame. That is deterministic and rarely what a reviewer wants to see; prefer a story showing the loaded state, with the spinner covered by a separate deliberate story. -
Scroll anchoring. A page that scrolls smoothly during capture produces a shifted image.
scroll-behavior: autoin the injected stylesheet removes it.
Where to put the motion settings
The same three settings — the animations option, the zero-duration stylesheet, and reduced-motion emulation — need to apply to every capture in the suite, and there are three plausible homes for them. Choosing badly means half the suite is stabilised and the other half is not.
In each assertion. Passing animations: 'disabled' per toHaveScreenshot call is explicit and immediately visible, and it is the option people reach for first. It also means a new test written without it is silently unstable, which is a poor default for something every capture needs.
In the Playwright config. expect.toHaveScreenshot.animations and stylePath set the default for every assertion in the project, so a new test inherits the stabilisation without its author having to know. This is the right home for a Playwright-driven suite.
// playwright.config.ts
export default defineConfig({
expect: {
toHaveScreenshot: {
animations: 'disabled',
stylePath: './test/screenshot.css', // the zero-duration rules
maxDiffPixelRatio: 0.002,
},
},
use: { reducedMotion: 'reduce', deviceScaleFactor: 1 },
});
In the test-runner’s preVisit hook. For a Storybook-driven suite the equivalent home is .storybook/test-runner.ts, which runs before every story regardless of who wrote it.
The principle in all three cases is that stabilisation should be a property of the environment rather than of the individual test. A setting a test author has to remember is a setting that will eventually be forgotten, and the resulting flake will be blamed on the tooling rather than on the omission.
FAQ
Does disabling animations hide animation bugs?
It hides bugs in the animation, which a still image could never have caught anyway. What it preserves is the ability to catch every bug in the final state, which is where regressions actually accumulate. If the motion itself is worth asserting, do it with a play function checking that a class is applied or an event fires — not with a screenshot.
Why does the diff persist after setting animations: 'disabled'?
Because something is animating outside CSS. A requestAnimationFrame loop, a canvas render, a video, or a library manipulating styles imperatively will all keep moving. The injected zero-duration stylesheet catches some of these; the rest need an explicit pause hook or, failing that, a tight mask over just that element.
Should the zero-duration stylesheet ship in the application?
No — it belongs to the capture environment only. Injecting it per test, or through a stylePath in the Playwright config, keeps it out of the bundle. A global stylesheet that disables motion in production is a different decision entirely, and one that should be driven by the user’s own reduced-motion preference rather than by testing convenience.
Should the baseline be regenerated after disabling motion?
Yes, once, and deliberately. Baselines captured while motion was still running recorded arbitrary frames, so comparing a newly stable capture against one of them produces a diff that means nothing. Regenerate the affected baselines in a single pull request that changes nothing else, so every diff in it has exactly one explanation, and review a sample to confirm each now shows the settled state rather than a mid-transition frame.
Related
- Dynamic Content Masking — the parent section on stabilising a snapshot before it is compared
- Masking Timestamps and Dates in Playwright Screenshots — the clock half of the same determinism problem
- Pixel Diff Algorithms — measuring the noise floor once motion is removed
- Tolerance Thresholds — why a stable capture lets you keep a tight budget