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.

Where a capture lands inside a transition A timeline of a two-hundred-millisecond fade. A capture requested at zero begins immediately. Eight milliseconds in, the element is at forty percent opacity, highlighted as where one run captured. At a hundred and fifty milliseconds another run captures at ninety percent. Only at two hundred milliseconds is the element settled at the state the snapshot was meant to record. capture requested 0 ms shot begins fade at 40% +8 ms opacity 0.4 fade at 90% +150 ms another run lands here settled +200 ms the state you meant Two runs of the same commit, two different images

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

The diff collapsing as motion is removed A repeated Playwright run of a success toast story. Before any change, three runs report 1.90, 0.60 and 3.10 percent of pixels differing. With Playwright's animations setting disabled, the figures fall to zero, zero and 0.02 percent. Adding a zero-duration stylesheet brings all three to exactly zero. playwright test --repeat-each=3 before: Toast/Success 1.90%, 0.60%, 3.10% differing pixels with animations: 'disabled' 0.00%, 0.00%, 0.02% plus zero-duration stylesheet 0.00%, 0.00%, 0.00%

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

What the animations setting reaches, and what it does not Six rows. CSS transitions, CSS animations and Web Animations API animations are all finished by Playwright's animations setting with nothing further required. A requestAnimationFrame loop is not reached and must be paused or masked. A canvas or WebGL render loop must be seeded and paused. A video element needs a poster frame or a mask. Motion type Playwright handles it Also needs CSS transition animations: 'disabled' nothing CSS animation animations: 'disabled' nothing Web Animations API animations: 'disabled' nothing requestAnimationFrame loop no pause it, or mask Canvas / WebGL render loop no seed and pause Video element no poster frame or mask
  • 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: auto in 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.