In a visual regression test, a card plays a 300 ms entry animation before it settles. Why is inserting a fixed delay before the screenshot a poor fix, and what would you do instead?
answer
- frames, not milliseconds
- a busy runner drops frames
- stop the motion rather than outrun it
- one injected stylesheet, everything static
- also the caret and smooth scroll
basics
~20 sA fixed delay is a guess: on a loaded CI machine the shutter still lands mid-animation, and on a fast one it only wastes time. Remove the motion instead — inject test-only CSS that disables animations and transitions and hides the text caret.
solid answer
~50 sA delay couples correctness to machine speed. Animation timing in a browser is driven by frames, and a busy CI runner drops frames, so the same 300 ms wait that was comfortable locally can capture the card halfway through its slide-in. Worse, the failure is intermittent, which is the expensive kind. The durable fix is to eliminate the moving parts rather than to outrun them: inject a test-only stylesheet that sets `animation: none !important` and `transition: none !important` on `*, *::before, *::after`, sets `scroll-behavior: auto` so programmatic scrolls land instantly, and sets `caret-color: transparent` so the blinking text cursor cannot appear in half your captures. Many capture APIs offer this directly — Playwright's `screenshot({ animations: 'disabled', caret: 'hide' })` freezes CSS animations at their end state — and where you genuinely need to assert an animated end state, wait on the state itself (the final class or the `transitionend`-driven attribute), not on the clock.
code
css · 10 lines/* test-only stylesheet injected before capture */
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
animation-iteration-count: 1 !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
scroll-behavior: auto !important;
caret-color: transparent !important;
}go deeper
Be able to say that a screenshot must be taken when nothing on screen is moving, and that the way to get there is to switch animations and transitions off for the test rather than to wait a fixed number of milliseconds.
Explain why the delay fails: animation progress advances per frame and a loaded CI runner drops frames, so the same wait lands in a different place each run. Describe the injected stylesheet and what each declaration neutralises.
Show judgment about what the capture is for: disable motion globally in a shared capture helper, but when the animated end state is the assertion, gate on the component's own settled state. Account for the non-CSS movers — media, timers, canvas.
Own the policy: a capture path that permits sleeps will accumulate them, so make the stabilisation layer shared and mandatory, and treat any test that needs its own bespoke wait as a signal the component lacks an observable settled state worth adding.
## Why time is the wrong thing to wait on A screenshot is a sample of a continuous process. Anything moving at capture time — a CSS transition, a keyframe animation, a spinner, a blinking caret, an autoplaying GIF or video — has a different value in every run, and the pixel comparison faithfully reports each one as a difference. The instinctive fix, "wait a bit longer than the animation," fails for a structural reason: CSS animation progress is advanced per rendered frame, and CI runners render frames unevenly. A machine running four browser instances under load, with no GPU, can drop enough frames that a 300 ms animation is still visibly in motion 400 ms of wall-clock later. So the sleep must be tuned to the *worst* machine, which means every run on every other machine pays that cost — a suite of 200 captures with a 500 ms cushion each burns nearly two minutes doing nothing. And the tuning is never finished: someone adds a slower animation, or CI gets busier, and the flakes come back. There is also a subtler trap. Even an animation that has *finished* can leave the element on a fractional pixel offset (a `transform: translateY(0.4px)` residue from an easing curve, or a composited layer that has not been re-rasterised), so the "settled" frame is not bit-identical to the settled frame from the previous run. ## Remove the motion instead The robust approach is to make the page static by construction. A small test-only stylesheet, injected before the page renders, covers most of it: ```css *, *::before, *::after { animation-duration: 0s !important; animation-delay: 0s !important; animation-iteration-count: 1 !important; transition-duration: 0s !important; transition-delay: 0s !important; scroll-behavior: auto !important; caret-color: transparent !important; } ``` A few notes on the specific declarations. Zeroing the *durations* rather than writing `animation: none` is often preferable: `none` cancels the animation entirely, so an element whose visible end state is produced by the keyframes (a fade-in from `opacity: 0`) would stay invisible, whereas a zero duration jumps straight to the final frame. Which you want depends on how the component is written, and it is worth checking both against the component you are stabilising. `scroll-behavior: auto` neutralises smooth scrolling so a programmatic scroll has finished by the time the next statement runs. `caret-color: transparent` deals with the blinking text cursor, which is a classic source of "this one input has a 1×16 px diff, sometimes." Tools often expose the same idea as an option. Playwright's `page.screenshot({ animations: 'disabled' })` fast-forwards finite CSS animations and transitions to their end state and disables infinite ones, and `caret: 'hide'` removes the cursor. Use whichever mechanism your capture layer offers — the principle is identical, and a question about it should make sense whatever tool you are holding. ## Do not reach for prefers-reduced-motion A tempting shortcut is to emulate `prefers-reduced-motion: reduce` and let the app's own media query turn animation off. It is not a substitute. It only works for animations the team remembered to guard, third-party components ignore it entirely, and — more importantly — you are now screenshotting the reduced-motion *variant* of the UI rather than the one users see. That is a legitimate thing to test deliberately; it is not a stabilisation strategy. ## When the animated end state is the thing under test Sometimes the point of the test is that the drawer really ended up open. Assert the state, not the elapsed time: wait for the class, attribute or accessible state the component sets when it settles (`[data-state="open"]`, `aria-expanded="true"`), or for the element to be at its final position, and capture after that. The general shape is the one that makes all UI tests durable — wait for an observable condition that the app itself controls, never for a duration you guessed. ## The other moving pixels Animations are the loud case; the same treatment applies to everything else that moves on its own: - **Video and animated GIF** — pause media and seek to a fixed time, or replace with a poster in the test build. - **Spinners and progress bars** — these should not be on screen at all in a stable capture; wait for the loaded state. - **Auto-rotating carousels** — stop the timer, or drive the component to a fixed slide. - **Anything driven by requestAnimationFrame** — freezing CSS is not enough; the component needs a way to be pinned to a deterministic state. A capture that still contains a moving element is not stabilised, it is merely lucky; raising the diff tolerance to cover the motion just makes the test blind to real regressions in that area too.
- Why might zeroing animation-duration be safer than setting animation: none?Because `none` cancels the animation, so a component whose visible end state comes from its keyframes — fading in from `opacity: 0`, sliding in from a translated position — would be captured invisible or offset. A zero duration still applies the animation, it just lands on the final frame immediately. Check the component both ways before committing to one.
- Would emulating prefers-reduced-motion: reduce be a good general way to stabilise captures?No. It only affects animations the code explicitly guards behind that media query, third-party widgets ignore it, and you end up baselining the reduced-motion variant rather than the default UI. It is a valid thing to test on purpose, but it is not a stabilisation mechanism.
- The test needs to prove a drawer ends up open, so you cannot skip past the animation entirely. How do you capture that deterministically?Wait on the state, not the duration: the component's settled marker — a `data-state` attribute, `aria-expanded="true"`, or the final class — then capture. Combined with zeroed durations the drawer reaches that state on the next frame, so the assertion is both fast and exact regardless of machine speed.
- What still moves in a capture after all CSS animation is disabled?Anything the browser or the app drives outside CSS: autoplaying video and animated GIFs, spinners still on screen because the loaded state was never awaited, carousels on a JavaScript timer, and requestAnimationFrame-driven canvas work. Each needs its own pin — pause and seek media, wait for loaded state, stop timers, or render a fixed frame.
saying these in an interview costs you the question
- Tuning a sleep until the animation flake stops
- Assuming 300 ms of animation is over after 300 ms
- Raising the pixel tolerance to cover moving elements
- Treating prefers-reduced-motion emulation as full stabilisation
- Forgetting the blinking caret and smooth scrolling