---
title: "Canvas and WebGL (motion)"
description: "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…"
canonical: https://void-design.vercel.app/docs/motion/webgl-canvas
lastModified: 2026-09-16
---

# Canvas and WebGL

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 `<video>` 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 `<video poster preload="none" playsInline loop>` played in view |
| 3D product | pre-rendered AVIF sequence or a short video |
