Support data from webstatus.dev (web-features/BCD), checked 2026-09-16. Choreography, durations and easing tokens are in the motion skill. This file covers the performance side.
1. What is cheap to animate
| Property | Cost | Use |
|---|---|---|
transform (translate, scale, rotate), opacity |
Compositor only | Everything |
filter, clip-path |
Paint-risky; sometimes composited in Chromium | Small elements, short durations |
background-color, color, border-color |
Paint | Small elements (buttons, links) |
box-shadow |
Paint, expensive on large areas | Cross-fade an opacity on a pseudo-element that holds the shadow instead |
backdrop-filter |
Very expensive over large or scrolling content | One sticky header at most. Never 8 stacked "progressive blur" layers; use mask-image |
width, height, top, left, margin, padding, font-size, grid-template-* |
Layout every frame + CLS | Never (exception: the 0fr → 1fr accordion, §6) |
Animating transform never counts as a layout shift. Animating top does.
/* Shadow lift without animating box-shadow */
.card { position: relative; transition: transform var(--duration-fast) var(--ease-out); }
.card::after { content: ""; position: absolute; inset: 0; border-radius: inherit; box-shadow: var(--shadow-3);
opacity: 0; transition: opacity var(--duration-fast) var(--ease-out); pointer-events: none; }
@media (hover: hover) and (pointer: fine) {
.card:hover { transform: translateY(-1px); }
.card:hover::after { opacity: 1; }
}will-change
- It's a last resort. It creates a stacking context and a layer up front, which can break
z-indexandposition: fixedchildren. - Set it just before an animation and remove it afterwards. Never on
html,bodyor large wrappers. At most 3 static uses per route.
el.style.willChange = "transform";
el.addEventListener("transitionend", () => { el.style.willChange = ""; }, { once: true });Containing-block traps
- A
transform,filterorbackdrop-filteron an ancestor makesposition: fixedchildren position against that ancestor. Portal overlays todocument.body. On page-transition wrappers useanimation-fill-mode: backwardsso the transform doesn't persist. overflow-x: hiddenonhtml/bodybreaksposition: sticky. Useoverflow-x: clip.- Full-height layouts:
min-h-svh/dvh, not100vh.
2. Reduced motion (hard gate)
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 1ms !important;
animation-delay: 0ms !important; /* important with fill-mode both, or elements stay hidden */
animation-iteration-count: 1 !important;
transition-duration: 1ms !important;
scroll-behavior: auto !important;
}
::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; }
}Keyframes must end in the visible state so a 1 ms animation lands on a finished frame. JS: see react-pitfalls.md §3 for usePrefersReducedMotion. Motion: .
3. Scroll-linked effects without scroll listeners
Reveal on enter (works everywhere)
@media (prefers-reduced-motion: no-preference) {
[data-reveal] { opacity: 0; transform: translateY(12px); transition: opacity 500ms var(--ease-out), transform 500ms var(--ease-out); }
[data-reveal].is-in { opacity: 1; transform: none; }
}"use client";
import { useEffect } from "react";
/** Mount once in the root layout. Reveals [data-reveal] elements the first time they enter. */
export function RevealObserver() {
useEffect(() => {
const io = new IntersectionObserver((entries) => {
for (const e of entries) if (e.isIntersecting) { e.target.classList.add("is-in"); io.unobserve(e.target); }
}, { rootMargin: "0px 0px -10% 0px" });
document.querySelectorAll("[data-reveal]").forEach((el) => io.observe(el));
return () => io.disconnect();
}, []);
return null;
}Limits: never on the hero or h1 (LCP). Not on every section (at most a few set pieces per page). Content must be readable with JS disabled: the opacity: 0 rule only applies under no-preference, and crawlers read HTML, not pixels. If you need it to be JS-proof, gate the rule on a .js class that you set on .
Scroll-driven animations (progressive enhancement)
Support: Chrome/Edge 115+, Safari 26+, Firefox: no (flag only). Status "limited". Always wrap the rule in @supports, and the page must look complete without it.
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.parallax-img {
animation: drift linear both;
animation-timeline: view();
animation-range: entry 0% exit 100%;
}
@keyframes drift { from { transform: translateY(-6%) scale(1.06); } to { transform: translateY(6%) scale(1.06); } }
.read-progress {
position: fixed; inset: 0 0 auto 0; height: 2px; background: var(--brand); transform-origin: 0 50%;
animation: grow linear both; animation-timeline: scroll(root);
}
@keyframes grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }
}
}These run off the main thread in Chromium when they animate transform/opacity.
If JS must read scroll
let ticking = false;
addEventListener("scroll", () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
const y = scrollY; // one read per frame
header.toggleAttribute("data-scrolled", y > 8); // one write per frame, no React state
ticking = false;
});
}, { passive: true });wheel,touchstartandtouchmovelisteners are{ passive: true }unless you truly callpreventDefault()(then attach to the element, neverwindow).- Use
matchMedia(...).addEventListener("change")instead ofresize+innerWidthstate. - Never call
getBoundingClientRectinside a rAF loop. Measure in aResizeObserverand cache the value. - No smooth-scroll libraries (Lenis, locomotive). They make scrolling main-thread-bound, so any long task freezes scroll. No GSAP ScrollTrigger pinning.
4. View Transitions
| Feature | Chrome/Edge | Firefox | Safari | Status | Use |
|---|---|---|---|---|---|
Same-document document.startViewTransition() |
111 | 144 | 18 | Baseline newly (2025-10) | Yes, as enhancement |
view-transition-class |
125 | 144 | 18.2 | Baseline newly | Yes |
:active-view-transition |
125 | 147 | 18.2 | Baseline newly (2026-01) | Yes |
Cross-document @view-transition { navigation: auto } |
126 | ✗ | 18.2 | Limited | MPA/Astro only |
| Element-scoped view transitions | 147 | ✗ | ✗ | Experimental | No |
function withTransition(update: () => void) {
if (!document.startViewTransition || matchMedia("(prefers-reduced-motion: reduce)").matches) return update();
document.startViewTransition(update);
}- React 19.2
+ Next 16(16.2+) integrate this with navigations. - Name ≤ 10 elements per transition (each becomes a captured layer). Page-level ≤ 400 ms, UI ≤ 250 ms.
- Theme toggles: disable transitions during the swap (see the
craftskill). A circular-reveal view transition is an optional flourish, gated by reduced motion.
/* MPA / Astro cross-document */
@view-transition { navigation: auto; }5. Long pages: content-visibility
Baseline newly available (Chrome 108, Firefox 130, Safari 26).
.section-offscreen { content-visibility: auto; contain-intrinsic-size: auto 900px; }- Only on repeated sections below the first viewport on pages taller than 3 viewports, and on long lists. Never the LCP.
autoincontain-intrinsic-sizeremembers the real size after first render, which prevents scrollbar jumps.- Content stays in the accessibility tree and find-in-page. Reading layout (
getBoundingClientRect) inside a skipped section cancels the benefit. - DOM over ~1,400 nodes: virtualize lists over ~200 rows (
@tanstack/react-virtual) and delete duplicate mobile/desktop markup.
6. Cheap patterns that replace JS
/* Accordion height without animating height */
.acc-panel { display: grid; grid-template-rows: 0fr; transition: grid-template-rows var(--duration-base) var(--ease-out); }
.acc-panel[data-open="true"] { grid-template-rows: 1fr; }
.acc-panel > div { overflow: hidden; }
/* Popover/dialog entry with @starting-style (Chrome 117, Firefox 129, Safari 17.5) */
[popover], dialog {
transition: opacity var(--duration-fast) var(--ease-out), transform var(--duration-fast) var(--ease-out),
overlay var(--duration-fast) allow-discrete, display var(--duration-fast) allow-discrete;
opacity: 0; transform: scale(0.96);
}
[popover]:popover-open, dialog[open] { opacity: 1; transform: none; }
@starting-style { [popover]:popover-open, dialog[open] { opacity: 0; transform: scale(0.96); } }interpolate-size: allow-keywords (height to auto) is Chromium-only. Treat it as enhancement.
7. Infinite animations must pause offscreen
.marquee-track { animation: marquee 30s linear infinite; }
[data-paused] .marquee-track { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) { .marquee-track { animation: none; } }"use client";
import { useEffect, useRef, type ReactNode } from "react";
/** Sets data-paused when offscreen or when the tab is hidden. Wrap any infinite CSS animation. */
export function PauseOffscreen({ children, className }: { children: ReactNode; className?: string }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
let visible = true;
const sync = () => el.toggleAttribute("data-paused", !visible || document.hidden);
const io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; sync(); });
io.observe(el);
document.addEventListener("visibilitychange", sync);
return () => { io.disconnect(); document.removeEventListener("visibilitychange", sync); };
}, []);
return <div ref={ref} className={className}>{children}</div>;
}Lists share one ticker (one setInterval for all "3 min ago" labels), not one per item. Spinners and carets animate in CSS, not with setState.
8. WebGL / canvas hook
// components/shader-backdrop.tsx
"use client";
import { useEffect, useRef } from "react";
type Draw = (gl: WebGL2RenderingContext, timeMs: number) => void;
type Setup = (gl: WebGL2RenderingContext) => Draw; // compile program, return a per-frame draw
export function useWebGLLoop(setup: Setup, { fps = 30, maxDpr = 1.5 } = {}) {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current;
if (!canvas) return;
const gl = canvas.getContext("webgl2", { antialias: false, powerPreference: "low-power" });
if (!gl) return; // keep the CSS fallback background
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
let draw = setup(gl);
let raf = 0, last = 0, visible = true, lost = false;
const interval = 1000 / fps;
const resize = () => {
const dpr = Math.min(devicePixelRatio || 1, maxDpr);
const { width, height } = canvas.getBoundingClientRect(); // measured here, never inside the loop
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
gl.viewport(0, 0, canvas.width, canvas.height);
if (reduced) draw(gl, 0); // redraw the static frame
};
const frame = (t: number) => {
raf = requestAnimationFrame(frame);
if (t - last < interval) return;
last = t;
draw(gl, t);
};
const start = () => { if (!raf && visible && !document.hidden && !lost && !reduced) raf = requestAnimationFrame(frame); };
const stop = () => { cancelAnimationFrame(raf); raf = 0; };
const onVisibility = () => (document.hidden ? stop() : start());
const onLost = (e: Event) => { e.preventDefault(); lost = true; stop(); };
const onRestored = () => { lost = false; draw = setup(gl); resize(); start(); };
const ro = new ResizeObserver(resize);
ro.observe(canvas);
const io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; visible ? start() : stop(); });
io.observe(canvas);
document.addEventListener("visibilitychange", onVisibility);
canvas.addEventListener("webglcontextlost", onLost);
canvas.addEventListener("webglcontextrestored", onRestored);
resize();
reduced ? draw(gl, 0) : start();
return () => {
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
canvas.removeEventListener("webglcontextlost", onLost);
canvas.removeEventListener("webglcontextrestored", onRestored);
gl.getExtension("WEBGL_lose_context")?.loseContext(); // free the context slot
};
}, [setup, fps, maxDpr]); // setup must be stable: define it at module scope or wrap it in useCallback
return ref;
}
// Usage: decorative, fixed to the viewport, fallback color matches the shader average
export function ShaderBackdrop({ setup }: { setup: Setup }) {
const ref = useWebGLLoop(setup, { fps: 30, maxDpr: 1 });
return <canvas ref={ref} aria-hidden="true" className="pointer-events-none fixed inset-0 -z-10 size-full bg-bg-subtle" />;
}Load it after first paint: const ShaderBackdrop = dynamic(() => import("./shader-backdrop").then(m => m.ShaderBackdrop), { ssr: false }) from a client component, mounted in the root layout so navigation doesn't re-seed it.
More rules:
- No allocation inside the loop (
new Float32Array,Intl.NumberFormat, color objects). Hoist them. - Changing props updates uniforms through a ref. Don't put ref-backed values in effect deps (that destroys and rebuilds the context).
- Resolve CSS colors (
oklch(),var()) to RGB once, in an effect, before passing them as uniforms. - 2D canvas: use
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)on resize, never cumulativectx.scale. - Images drawn to a canvas must be same-origin (serve them via
/_next/image) or the canvas is tainted. - Status or loader canvases that convey meaning get
role="img"+aria-label. - Shader cost = pixels × ops × fps: 560 px at DPR 2 and 60 fps costs ~3.5× the same shader at DPR 1.5 and 30 fps.
9. Measuring smoothness
void smooth: scripted scroll (rAF frame times + Long Animation Frames), scripted interactions (INP), anddocument.getAnimations()for animated layout properties.- Pass: 0 ms LoAF blocking time during scroll; ≤ 5% frames > 25 ms at 4× CPU; ≤ 2
Layoutevents/s while only ambient animations run. - DevTools: Performance panel → "Layout shifts" and "Animations" tracks; Rendering → "Paint flashing" and "Layer borders".
Sources
- https://web.dev/articles/animations-guide · https://developer.mozilla.org/en-US/docs/Web/CSS/will-change
- https://developer.chrome.com/docs/web-platform/view-transitions
- https://web.dev/articles/content-visibility
- https://developer.chrome.com/docs/web-platform/long-animation-frames
- https://api.webstatus.dev/v1/features/scroll-driven-animations · …/view-transitions · …/content-visibility · …/starting-style
- https://github.com/darkroomengineering/lenis (limitations section)