---
title: "Rendering smoothness: animation, scroll, view transitions, canvas (speed)"
description: "Support data from webstatus.dev (web-features/BCD), checked 2026-09-16. Choreography, durations and easing tokens are in the motion skill."
canonical: https://void-design.vercel.app/docs/speed/rendering-smoothness
lastModified: 2026-09-16
---

# Rendering smoothness: animation, scroll, view transitions, canvas

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: `<MotionConfig reducedMotion="user">`.

## 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 `<html>`.

### 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 `<ViewTransition>` + Next 16 `<Link transitionTypes={[...]}>` (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 <url>`: 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

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