Skip to content

Reference · speed

Rendering smoothness: animation, scroll, view transitions, canvas

speed/references/rendering-smoothness.md320 linesupdated 16 Sept 2026

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.

css
/* 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-index and position: fixed children.
  • Set it just before an animation and remove it afterwards. Never on html, body or large wrappers. At most 3 static uses per route.
ts
el.style.willChange = "transform";
el.addEventListener("transitionend", () => { el.style.willChange = ""; }, { once: true });

Containing-block traps

  • A transform, filter or backdrop-filter on an ancestor makes position: fixed children position against that ancestor. Portal overlays to document.body. On page-transition wrappers use animation-fill-mode: backwards so the transform doesn't persist.
  • overflow-x: hidden on html/body breaks position: sticky. Use overflow-x: clip.
  • Full-height layouts: min-h-svh/dvh, not 100vh.

2. Reduced motion (hard gate)

css
@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)

css
@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; }
}
tsx
"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.

css
@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

ts
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, touchstart and touchmove listeners are { passive: true } unless you truly call preventDefault() (then attach to the element, never window).
  • Use matchMedia(...).addEventListener("change") instead of resize + innerWidth state.
  • Never call getBoundingClientRect inside a rAF loop. Measure in a ResizeObserver and 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
ts
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 craft skill). A circular-reveal view transition is an optional flourish, gated by reduced motion.
css
/* MPA / Astro cross-document */
@view-transition { navigation: auto; }

5. Long pages: content-visibility

Baseline newly available (Chrome 108, Firefox 130, Safari 26).

css
.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.
  • auto in contain-intrinsic-size remembers 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

css
/* 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

css
.marquee-track { animation: marquee 30s linear infinite; }
[data-paused] .marquee-track { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) { .marquee-track { animation: none; } }
tsx
"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

tsx
// 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 cumulative ctx.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), and document.getAnimations() for animated layout properties.
  • Pass: 0 ms LoAF blocking time during scroll; ≤ 5% frames > 25 ms at 4× CPU; ≤ 2 Layout events/s while only ambient animations run.
  • DevTools: Performance panel → "Layout shifts" and "Animations" tracks; Rendering → "Paint flashing" and "Layer borders".

Sources