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)
- It's a marketing, portfolio or launch page (or the canvas is the product: a chart, editor, game).
- It's the only T3/T4 effect on the page, and there is no other T2 signature.
- It has a job: explains the product (data flowing, a network, a terminal), or is the brand's visual identity. Not "background vibe".
- The page reads completely without it (text, CTA and LCP are HTML).
- 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
// 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;
}// 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
// 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
<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 |