Skip to content

Reference · motion

Canvas and WebGL

motion/references/webgl-canvas.md189 linesupdated 16 Sept 2026

A canvas is the most expensive way to make a page feel alive. Use one only when it is the page's single signature and a CSS version (inspire/references/effect-downgrades.md, components) can't carry the idea.

1. Gate (all must be yes)

  1. It's a marketing, portfolio or launch page (or the canvas is the product: a chart, editor, game).
  2. It's the only T3/T4 effect on the page, and there is no other T2 signature.
  3. It has a job: explains the product (data flowing, a network, a terminal), or is the brand's visual identity. Not "background vibe".
  4. The page reads completely without it (text, CTA and LCP are HTML).
  5. You can name its static fallback: a CSS gradient or a pre-rendered AVIF frame matching its average color.

If any answer is no, use T0/T1: grain, one light, masked grid, CSS scanlines, a looped muted with a poster, or an AVIF still.

2. Hard rules (from shipped bugs)

Rule Why
1 WebGL context per page, never inside .map(), cards or avatars (lint/webgl-in-map, smooth/multiple-webgl-contexts) browsers cap at ~8–16 contexts and silently kill the oldest; the owner's orb avatars died at the 9th
Cancel rAF offscreen (IntersectionObserver) and when document.hidden; never "skip the draw" inside a running loop (lint/raf-without-cancel, smooth/raf-loop-idle) a skipped frame still wakes the main thread 60×/s
Never pause on user inactivity the owner's backdrop froze while they were reading
DPR ≤ 1.5 (1 for full-viewport backdrops), ≤ 30fps ambient, render scale ≤ 0.6 for full-bleed shaders shader cost = pixels × ops × fps: DPR 2 @ 60fps ≈ 3.5× DPR 1.5 @ 30fps
Backdrops sized to the viewport (fixed inset-0), never the page a page-sized canvas cost ~30 MB of backing store
No allocation in the frame loop (typed arrays, hoisted formatters, gradients) GC stutter
Measure in ResizeObserver, never getBoundingClientRect per frame forced layout per frame
2D: ctx.setTransform(dpr,0,0,dpr,0,0) on resize, never cumulative ctx.scale drawing drifts after resizes
Handle webglcontextlost/restored; WEBGL_lose_context.loseContext() on unmount frees the context slot
Props update uniforms via refs; never rebuild the program on prop change; never put ref-backed values in effect deps rebuilt contexts flash and leak
next/dynamic(() => import(...), { ssr: false }) from a client component; reserve the box text paints first, no CLS
Mount persistent backdrops once in the root layout re-seeding on every navigation
Reduced motion → draw one frame and stop vestibular safety
Decorative: aria-hidden="true" + pointer-events-none; meaningful: role="img" + aria-label a11y
Colors from tokens resolved once to RGB (shaders can't read oklch()/var()) tokens stay the single source
ogl (10–14 KB) or raw WebGL2 for full-screen quads; cobe (5 KB) for globes; no three for 2D quads (140 KB+) budget

3. WebGL2

Use the useWebGLLoop hook in speed/references/rendering-smoothness.md §8 (context loss, DPR/fps caps, IO + visibility pause, reduced-motion frame, cleanup). Pass a module-scope setup so the effect doesn't re-run.

4. Canvas 2D hook

tsx
// src/components/fx/use-canvas-2d.ts
"use client";
import { useEffect, useRef } from "react";

export type Draw2D = (ctx: CanvasRenderingContext2D, timeMs: number, size: { w: number; h: number }) => void;

/** Viewport- or element-sized 2D canvas loop: DPR + fps capped, paused offscreen/hidden, static under reduced motion. */
export function useCanvas2D(draw: Draw2D, { fps = 30, maxDpr = 1.5 } = {}) {
  const ref = useRef<HTMLCanvasElement>(null);
  const drawRef = useRef(draw);
  useEffect(() => { drawRef.current = draw; });                  // latest draw without restarting the loop

  useEffect(() => {
    const canvas = ref.current;
    const ctx = canvas?.getContext("2d");
    if (!canvas || !ctx) return;
    const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
    const size = { w: 0, h: 0 };
    const interval = 1000 / fps;
    let raf = 0, last = 0, visible = true;

    const paint = (t: number) => drawRef.current(ctx, t, size);
    const resize = () => {
      const dpr = Math.min(window.devicePixelRatio || 1, maxDpr);
      const rect = canvas.getBoundingClientRect();              // measured on resize only
      size.w = rect.width; size.h = rect.height;
      canvas.width = Math.round(rect.width * dpr);
      canvas.height = Math.round(rect.height * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      paint(last);
    };
    const loop = (t: number) => {
      raf = requestAnimationFrame(loop);
      if (t - last < interval) return;
      last = t;
      paint(t);
    };
    const start = () => { if (!raf && visible && !document.hidden && !reduced) raf = requestAnimationFrame(loop); };
    const stop = () => { cancelAnimationFrame(raf); raf = 0; };
    const onVisibility = () => (document.hidden ? stop() : start());

    const ro = new ResizeObserver(resize);
    const io = new IntersectionObserver(([entry]) => { visible = !!entry?.isIntersecting; visible ? start() : stop(); });
    ro.observe(canvas);
    io.observe(canvas);
    document.addEventListener("visibilitychange", onVisibility);
    resize();
    start();

    return () => {
      stop();
      ro.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, [fps, maxDpr]);

  return ref;
}
tsx
// src/components/fx/signal-field.tsx — usage
"use client";
import { useCanvas2D } from "./use-canvas-2d";
import { useTokenColor } from "./use-token-color";

const COUNT = 120;
const xs = new Float32Array(COUNT), ys = new Float32Array(COUNT);   // allocated once
for (let i = 0; i < COUNT; i++) { xs[i] = Math.random(); ys[i] = Math.random(); }

export function SignalField() {
  const brand = useTokenColor("--brand");
  const ref = useCanvas2D((ctx, t, { w, h }) => {
    ctx.clearRect(0, 0, w, h);
    ctx.fillStyle = brand.current;
    for (let i = 0; i < COUNT; i++) {
      const y = (ys[i]! * h + t * 0.01 * (1 + (i % 3))) % h;
      ctx.globalAlpha = 0.15 + (i % 5) * 0.08;
      ctx.fillRect(xs[i]! * w, y, 1.5, 1.5);
    }
  });
  return <canvas ref={ref} aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 size-full" />;
}
// Parent: const SignalField = dynamic(() => import("./signal-field").then(m => m.SignalField), { ssr: false });
// inside a `relative isolate` section with a bg fallback, e.g. bg-bg-subtle.

Random seeds at module scope are fine because the component is ssr: false (no hydration mismatch).

5. Token colors for canvas and shaders

tsx
// src/components/fx/use-token-color.ts
"use client";
import { useEffect, useRef } from "react";

/** Resolves a CSS custom property (oklch, color-mix, var) to an rgb() string; updates on theme change. */
export function useTokenColor(name: `--${string}`) {
  const value = useRef("rgb(128 128 128)");
  useEffect(() => {
    const probe = document.createElement("canvas").getContext("2d")!;
    const read = () => {
      const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
      probe.clearRect(0, 0, 1, 1);
      probe.fillStyle = raw;                                    // the browser parses oklch()
      probe.fillRect(0, 0, 1, 1);
      const [r, g, b] = probe.getImageData(0, 0, 1, 1).data;
      value.current = `rgb(${r} ${g} ${b})`;
    };
    read();
    const mo = new MutationObserver(read);                        // .dark / data-theme toggles
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme"] });
    return () => mo.disconnect();
  }, [name]);
  return value;                                                   // read .current inside the draw loop
}

For WebGL uniforms, divide the channels by 255. For next/og images and manifests, use the generated src/styles/void/tokens.ts hex mirror instead.

6. Fallback layering

tsx
<section className="relative isolate overflow-clip">
  {/* 1. CSS fallback: always present, matches the effect's average color */}
  <div aria-hidden="true" className="absolute inset-0 -z-20 bg-[radial-gradient(80%_60%_at_50%_0%,color-mix(in_oklch,var(--brand)_10%,var(--bg)),var(--bg))]" />
  {/* 2. The effect, loaded after paint, hidden under reduced data */}
  <SignalField />
  {/* 3. Content, fully readable over both */}
  <Container className="relative">…</Container>
</section>

Contrast: text over any effect must pass with the effect at its brightest frame. Keep effects out from behind body copy; put them behind the hero headline area or beside it.

7. CSS-first alternatives

Wanted T0/T1 version
Shader gradient / aurora 2–3 blurred radial layers drifting with translate keyframes 20–40s, paused offscreen (components → AuroraBackdrop)
CRT / scanlines repeating-linear-gradient scanlines + grain + inset vignette box-shadow: inset 0 0 120px var(--bg)
Particle field static SVG dots + one slow translate loop on the layer
Globe a static SVG/AVIF render; cobe only if it must rotate
Dithered video AVIF still with grain; or muted played in view
3D product pre-rendered AVIF sequence or a short video