Skip to content
All writings
CSSView TransitionsDebugging

animation: none does not cancel an animation — it defers it

3 min readSource: ROADMAP 22
375msOPACITY 1OPACITY 0

Every route change on this site flickered. The page would cross-fade in, blank out completely, then fade in a second time. The cause was a rule I had written specifically to prevent two animations from playing over each other.

The setup

Route changes use React's <ViewTransition>, which gives the browser a root cross-fade for free. The site also has a .page-in entry animation in CSS — opacity 0 to 1 with animation-fill-mode: both — which exists so first paint does not wait on hydration.

During a view transition both would run on the same content. So I gated one on the other, which reads perfectly sensibly:

globals.css — the bug
:root:active-view-transition .page-in {
  animation: none;
}

What actually happened

Patching document.startViewTransition and sampling the wrapper's computed opacity every frame gave the timeline:

  15ms  startViewTransition called
 14-366  animation: none, opacity 1   <- suppression working
  366ms  vt.finished, page fully visible
  375ms  opacity 0                   <- the flicker
410-894  fades in a second time

The suppression worked perfectly for the whole transition. Nine milliseconds after the transition finished, the page went to fully transparent and faded in again.

Why

animation: none does not cancel a running animation. It makes the element ineligible to have one. The moment :active-view-transition stopped matching, animation-name resolved back to page-in, the animation became eligible, and it started — fresh, from the beginning. fill-mode: both then snapped it straight to from { opacity: 0 }.

The fix is structural, not a stronger rule

The real error was where the animation lived. .page-in sat on the wrapper in template.tsx, and in the Next.js App Router template.tsx remounts on every navigation — so the animation was restarting per route by design, and the gate was papering over that.

Moving .page-in onto <main> in layout.tsx fixed it. Layouts mount once per document, so the animation runs exactly once, on first paint, which is the only thing it was ever for. Route changes belong to the view transition. The gate was deleted rather than repaired.

Verified the same way it was diagnosed: across navigations to three routes, <main> holds opacity 1 for the entire transition and page-in fires no animationstart at all. On a cold load it fires once, 33ms to 533ms.

Worth noting the fix was only findable because the symptom was measured rather than eyeballed. "It flickers" and "opacity drops to 0 nine milliseconds after vt.finished" are the same bug, but only one of them tells you what to change.