animation: none does not cancel an animation — it defers it
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:
: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 timeThe 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.