---
title: "Section backgrounds: alive surfaces that stay fast (motion)"
description: "The owner's ask, verbatim: sections should sit on \"real, alive backgrounds\", not flat --bg, pointing at React Bits' Backgrounds category (Liquid Ether…"
canonical: https://void-design.vercel.app/docs/motion/backgrounds
lastModified: 2026-09-16
---

# Section backgrounds: alive surfaces that stay fast

The owner's ask, verbatim: sections should sit on "real, alive backgrounds", not flat `--bg`,
pointing at React Bits' Backgrounds category (Liquid Ether, Ballpit, Pixel Snow, Grid Distortion,
"and so many more") as the quality bar. This doc is the decision-first reference: what to reach
for per section, the cheap tier to try before WebGL, and paste-ready implementations for the T4
tier, all measured with void's own CLI rather than asserted.

**Licence, restated because it is load-bearing here:** React Bits and Animate UI are MIT +
Commons Clause (no redistributing "a ported version"); Aceternity forbids redistributing ports
outright. Every implementation below is written from scratch from the underlying technique
(value-noise domain warping, ordered/Bayer dithering, GPU-driven particle motion, vertex-shader
grid displacement — decades-old, unowned maths) — never a transcription of any library's source.
See `components` skill rule 1 and its licence matrix.

Full WebGL hard rules (context budget, DPR, fps, pause/cancel, cleanup, reduced motion) live in
`references/3d-scenes.md` and `references/webgl-canvas.md` — this doc doesn't restate them, it
applies them to *backgrounds* specifically and links back per recipe.

## 1. Decision table

Cost tiers are **measured**, not guessed — see §7. "Evokes" is the feeling a great-sites teardown
would name it as; use that word, not the library name, when picking one for a section.

| Effect | Evokes | Tier | Measured cost | Use when | Don't use when |
|---|---|---|---|---|---|
| Dot / line grid | structure, precision, blueprint | T0 (static) / T1 (drift) | 0 KB JS, 0% dropped both profiles | dev-tool/infra hero or footer, a section that needs quiet texture under dense text | you already have a grain layer in the same viewport (two textures reads as noise) |
| Gradient mesh (aurora) | warmth, depth, "something is alive here" | T1 | 0 KB JS; **5.3% dropped desktop** when 3 blurred layers share a scroll pass with other effects (§7) | one hero, alone, one instance per page | stacked with grain/dots in the same viewport, or on a route with any other infinite T1 animation (budget is ≤3 infinite/viewport, ≤1 paint-bound — a blurred layer counts as paint-bound) |
| Pointer spotlight | focus, "this responds to you" | T1/T2, ~0.3 KB | rAF-written CSS var, 0 React renders | a card grid or a single feature section, `pointer-fine` only | touch-primary routes (no pointer to follow — ship the static fallback) |
| Grain (`grain` utility, already in `packages/tokens/css/base.css`) | material, film, "not flat" | T0 | 0 KB, existing SVG turbulence data URI | any surface that reads too flat/plasticky | opacity above the direction's `--grain-opacity` ceiling (0.02–0.06) — checked, don't hand-tune per section |
| **Fluid / liquid distortion** (FluidEther) | premium, organic, "something is moving underneath" | T4 | +20.1 KB gzip JS · **did not hold the desktop dropped-frame budget in testing** (§7) | the single hero, alone, nothing else T2+ on the page | any secondary section, any route with text-heavy scroll below it, low-power devices (already gated) |
| **Particle field** (ParticleField) | depth, atmosphere, ballpit/snow energy | T4 | +19.4 KB gzip JS · 0% dropped both profiles, p95 16.7–16.8ms | a hero or one mid-page "moment" section, sparse text over it | list/table routes, anywhere near 100+/day interaction |
| **Pixel / dither grid** (PixelDither) | retro-technical, terminal energy, snow/static | T4 | +19.7 KB gzip JS · 0% dropped both profiles, p95 16.7–16.8ms | `terminal`/`precision` direction hero, a CLI or dev-tool launch section | `editorial`/`warm` directions (fights the material) |
| **Displacement grid** (DisplacementGrid) | interactive structure, "the grid reacts to you" | T4 | +19.6 KB gzip JS · 0% dropped both profiles, p95 16.7–16.8ms | a hero over a grid direction (`swiss`, `instrument`), pointer-fine audiences | touch-only routes (the whole point is the pointer warp) |
| **Shared host, 2 looks, 1 context** (BackgroundHost) | page-level continuity across several sections | T4 (one context) | +17.6 KB gzip JS combined · 0% dropped both profiles | a page that wants 2–3 *different* background looks across sections without a second WebGL context | a page where each section's look should load independently lazily (see §7 caveat — wire this one behind `requestIdleCallback` in production, not eagerly) |

Everything in this table is provided; nothing else needed a WebGL context to reach the React Bits
"Backgrounds" vocabulary — aurora/beams is the gradient mesh, dot/line grids are T0, grain is
already in the base layer.

## 2. Selection rules

1. **At most one expensive (T3/T4) background live at a time**, page-wide — this is `motion`'s
   existing "1 WebGL context, hard" rule applied to backgrounds specifically. Two sections that
   both want WebGL share one context via `BackgroundHost` (§6); they never mount two canvases.
2. **Sections don't repeat the same effect.** Pick by role, not habit: hero gets the one T4 (or a
   T1 gradient mesh if the page has no WebGL budget left), a mid-page "moment" section gets a T0/T1
   texture (dot grid, spotlight), the footer/CTA gets grain or nothing. Three sections in a row
   with the same look reads as a template, the exact tell `craft`'s anti-slop checklist flags for
   card grids (#17–19) — the same logic applies to backgrounds.
3. **Text-heavy sections get T0/T1 only**, never T3/T4. A live WebGL surface under a paragraph of
   body copy fights reading; reserve motion for hero/moment sections where the visual *is* the
   content. `craft` rule 3 (one accent, rationed) extends here: rationing applies to *motion*, not
   just colour.
4. **A hero survives losing its background entirely** (§4's poster/reduced-motion rule) — if the
   page reads worse with the canvas removed than with it, the H1 and CTA aren't carrying enough on
   their own; fix the copy/type before adding more visual.
5. **Pick the cheapest tier that carries the idea.** Run the ladder in §5 before reaching for §6.
   Most sections should land in §5 and never spend a WebGL context at all.

## 3. The non-negotiables

Every implementation below has these wired in, not left as a TODO. Stated once here, linked from
each recipe rather than repeated per file:

- **One shared WebGL context for the whole page** — `BackgroundHost` (§6), never a second canvas.
- **IntersectionObserver + `visibilitychange` pause that cancels the rAF** (not "skip the draw") —
  built into `use-gl-canvas.ts` (§6.1); this is `3d-scenes.md` rule 8/`webgl-canvas.md`'s top rule.
- **DPR ≤ 1.5** (1 for a true full-bleed backdrop) — and for a *full-bleed fragment shader*
  specifically, DPR alone isn't enough (§7's measured finding): also cap **render scale ≤ 0.6**
  (internal resolution below CSS size) for anything that shades every pixel every frame.
- **Render-on-demand where the effect allows** — not implemented below (every effect here is
  continuously alive by design, e.g. a fluid field never "settles"); if you adapt one of these into
  something that damps to a resting state (like `3d-scenes.md`'s scroll-linked object), stop the
  loop once damped values converge, same as that reference does.
- **No per-frame allocation, uniforms via refs** — colours resolve once per direction/theme change
  (`use-token-colors.ts`, §6.2), never per frame; particle/grid positions are closed-form functions
  of `uTime` evaluated in the vertex shader, never CPU-side arrays rewritten per frame.
- **Lazy after idle + intersection** — `use-lazy-webgl.ts` (§6.3): nothing WebGL imports before
  `requestIdleCallback` *and* the section is within 200px of the viewport.
- **Poster/static paint first** — the CSS gradient (or `var(--bg)`) underneath every canvas paints
  immediately; LCP measured 624–672ms across every route in §7, i.e. it never waits on WebGL.
- **Full static fallback** on `prefers-reduced-motion: reduce`, `navigator.hardwareConcurrency <= 4`,
  and `connection.saveData` — the poster simply stays; nothing WebGL ever imports.
- **Zero binary assets** — every effect here is procedural (noise/dither/closed-form motion), no
  texture fetch, no video.
- **Context-loss handling** (`webglcontextlost`/`restored`) and **full dispose on unmount**
  (geometry, program, `WEBGL_lose_context.loseContext()`, every listener) — in `use-gl-canvas.ts`.

## 4. Contrast: text over a live background

Text on top of any effect in this doc must still hit the tiers in `a11y` (§"Color and contrast"):
body ≥ 4.5:1, large/display text ≥ 3:1. **Never demote text to `--fg-faint` to "solve" a busy
background** — that token is 3:1 and reserved for disabled/decorative content, not a contrast
workaround.

Use a scrim between the background and the content instead:

```tsx
// src/components/fx/backgrounds/scrim.tsx
/**
 * Scrim -- guarantees text contrast over ANY live background. Put it between
 * the background and the content. `full` = flat overlay for text-heavy
 * sections; default = bottom-weighted gradient for a hero where the top
 * stays alive.
 */
export function Scrim({ full = false, className = "" }: { full?: boolean; className?: string }) {
  return <div aria-hidden="true" className={`bg-scrim ${full ? "bg-scrim--full" : ""} ${className}`} />;
}
```

```css
/* in backgrounds.css, @layer components */
.bg-scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(to bottom,
    color-mix(in oklch, var(--bg) 55%, transparent) 0%,
    color-mix(in oklch, var(--bg) 82%, transparent) 60%,
    var(--bg) 100%);
  pointer-events: none;
}
.bg-scrim--full { background: color-mix(in oklch, var(--bg) 72%, transparent); }
```

Built from `--bg`, so it re-tints per direction automatically. Measure with `void a11y` after
adding any background — `a11y/color-contrast` catches a scrim that's too thin.

## 5. Cheap-first ladder (try this before §6)

Most sections should stop here. In order of reach:

1. **Grain** — already in `packages/tokens/css/base.css` as the `grain` utility (SVG
   `feTurbulence`, opacity from `--grain-opacity`, 0 KB, procedural). Don't reimplement it.
2. **Dot / line grid** — `background-image: radial-gradient(...)` tiled, masked to fade at the
   edges. Optional 40s `transform` drift, paused under reduced motion.
3. **Gradient mesh (aurora)** — 2–3 blurred radial layers, one hue family
   (`color-mix(in oklch, var(--brand) N%, transparent)`), drifting via `transform` only
   (compositor-only, never `background-position`). This *is* void's `AuroraBackdrop` recipe
   (`components/references/recipes.md`) — same effect, cross-referenced here because it's the
   direct cheap answer to React Bits' Aurora/Silk.
4. **Masked radial spotlight** — pointer-follow glow, position written via `requestAnimationFrame`
   into a CSS custom property (never React state, never a per-pixel-move re-render).
5. **Scroll-driven CSS** — `animation-timeline: view()` for a reveal that only plays once per
   section; not for continuous ambient motion (that's what marks a background as alive, not what
   makes a heading appear).

```tsx
// src/components/fx/backgrounds/dot-grid.tsx -- T0/T1, zero JS (Server Component)
export function DotGrid({
  variant = "dots", cell = 28, opacity = 0.5, drift = false, className = "",
}: { variant?: "dots" | "lines"; cell?: number; opacity?: number; drift?: boolean; className?: string }) {
  return (
    <div
      aria-hidden="true"
      className={`bg-grid ${variant === "lines" ? "bg-grid--lines" : ""} ${drift ? "bg-grid--drift" : ""} ${className}`}
      style={{ ["--bg-grid-cell" as string]: `${cell}px`, ["--bg-grid-opacity" as string]: opacity }}
    />
  );
}
```

```tsx
// src/components/fx/backgrounds/gradient-mesh.tsx -- T1, zero JS (Server Component)
export function GradientMesh({ className = "" }: { className?: string }) {
  return (
    <div aria-hidden="true" className={`bg-mesh ${className}`}>
      <span className="bg-mesh__blob" />
    </div>
  );
}
```

```tsx
// src/components/fx/backgrounds/spotlight-follow.tsx -- T1/T2
"use client";
import { useEffect, useRef } from "react";

/** Writes --spot-x/-y via a ref-driven rAF loop, never React state, so
 *  pointermove never causes a re-render. Fine-pointer only; the CSS default
 *  (top-center spot) is the no-JS fallback. */
export function SpotlightFollow({ className = "" }: { className?: string }) {
  const ref = useRef<HTMLDivElement | null>(null);
  useEffect(() => {
    const el = ref.current;
    if (!el || !window.matchMedia("(hover: hover) and (pointer: fine)").matches) return;
    let raf = 0;
    let pending: { x: number; y: number } | null = null;
    const flush = () => {
      raf = 0;
      if (!pending) return;
      el.style.setProperty("--spot-x", `${pending.x}px`);
      el.style.setProperty("--spot-y", `${pending.y}px`);
    };
    const onMove = (e: PointerEvent) => {
      const rect = el.getBoundingClientRect();
      pending = { x: e.clientX - rect.left, y: e.clientY - rect.top };
      if (!raf) raf = requestAnimationFrame(flush);
    };
    el.addEventListener("pointermove", onMove, { passive: true });
    return () => { el.removeEventListener("pointermove", onMove); if (raf) cancelAnimationFrame(raf); };
  }, []);
  return <div ref={ref} aria-hidden="true" className={`bg-spotlight ${className}`} />;
}
```

```css
/* src/styles/backgrounds.css -- import after the direction file, before fx.css. Tokens only. */
@layer components {
  .bg-grid {
    position: absolute; inset: 0;
    background-image: radial-gradient(circle at 1px 1px, var(--line-strong) 1px, transparent 0);
    background-size: var(--bg-grid-cell, 28px) var(--bg-grid-cell, 28px);
    mask-image: radial-gradient(120% 100% at 50% 0%, black 30%, transparent 85%);
    opacity: var(--bg-grid-opacity, 0.5);
  }
  .bg-grid--lines {
    background-image:
      linear-gradient(to right, var(--line-subtle) 1px, transparent 1px),
      linear-gradient(to bottom, var(--line-subtle) 1px, transparent 1px);
  }
  .bg-grid--drift { animation: bg-grid-drift 40s linear infinite; }
  @keyframes bg-grid-drift { to { transform: translate(var(--bg-grid-cell, 28px), var(--bg-grid-cell, 28px)); } }
  @media (prefers-reduced-motion: reduce) { .bg-grid--drift { animation: none; } }

  /* 3 soft radial blobs, one hue family, translated on the compositor only. */
  .bg-mesh { position: absolute; inset: -10%; overflow: hidden; isolation: isolate; pointer-events: none; }
  .bg-mesh::before, .bg-mesh::after, .bg-mesh > .bg-mesh__blob {
    content: ""; position: absolute; width: 55%; height: 55%; border-radius: 50%;
    filter: blur(60px); will-change: transform;
  }
  .bg-mesh::before {
    top: 5%; left: 8%; background: color-mix(in oklch, var(--brand) 26%, transparent);
    animation: bg-mesh-drift-a 26s var(--ease-in-out) infinite alternate;
  }
  .bg-mesh::after {
    bottom: 0%; right: 5%; background: color-mix(in oklch, var(--brand) 14%, transparent);
    animation: bg-mesh-drift-b 32s var(--ease-in-out) infinite alternate;
  }
  .bg-mesh > .bg-mesh__blob {
    top: 30%; right: 25%; width: 40%; height: 40%;
    background: color-mix(in oklch, var(--surface-raised) 70%, var(--brand) 10%);
    animation: bg-mesh-drift-a 22s var(--ease-in-out) infinite alternate-reverse;
  }
  @keyframes bg-mesh-drift-a { from { transform: translate(0, 0) scale(1); } to { transform: translate(6%, 8%) scale(1.08); } }
  @keyframes bg-mesh-drift-b { from { transform: translate(0, 0) scale(1); } to { transform: translate(-8%, -5%) scale(1.05); } }
  @media (prefers-reduced-motion: reduce) {
    .bg-mesh::before, .bg-mesh::after, .bg-mesh > .bg-mesh__blob { animation: none; }
  }

  .bg-spotlight {
    position: absolute; inset: 0;
    background: radial-gradient(480px circle at var(--spot-x, 50%) var(--spot-y, 15%),
      color-mix(in oklch, var(--brand) 16%, transparent), transparent 70%);
    transition: background-color var(--duration-fast) var(--ease-out);
  }
}
```

## 6. WebGL tier (T4): shared infrastructure

`OGL` (Unlicense, ~14 KB gzip — already the house choice per `3d-scenes.md`; not a dep of
`templates/next` today, add it the same way that reference's `SceneCanvas` assumes). All four
effects in §7 build on two small hooks so the "one context" and "lazy load" rules live in one
place instead of four.

### 6.1 `use-gl-canvas.ts` — one context, every non-negotiable, per call site

```ts
// src/components/fx/backgrounds/use-gl-canvas.ts
"use client";
import { Renderer } from "ogl";
import type { OGLRenderingContext } from "ogl";
import { useEffect, useRef } from "react";

export type GLFrame = { gl: OGLRenderingContext; renderer: Renderer; time: number; width: number; height: number; dpr: number };
export type GLSetup = {
  onFrame: (frame: GLFrame) => void;
  onResize?: (width: number, height: number, dpr: number) => void;
  onDispose?: () => void;
};

const MAX_DPR = 1.5;
const TARGET_FPS = 30;
const FRAME_INTERVAL_MS = 1000 / TARGET_FPS;

export type UseGLCanvasOptions = {
  /** Internal render resolution as a fraction of CSS size (canvas.style stays
   *  full size; fragment cost drops by scale^2). A full-bleed per-pixel
   *  fragment shader is viewport-AREA-bound, not just DPR-bound -- see the
   *  FluidEther measurement in §7. Geometry-bound effects (points, lines)
   *  don't need this; leave at 1. */
  renderScale?: number;
};

/** One OGL WebGL context per call site: DPR<=1.5, fps cap, cancel (never
 *  skip) rAF offscreen/hidden/reduced-motion, ResizeObserver (never
 *  per-frame layout reads), context-loss handling, full dispose on unmount.
 *  To SHARE one context across several sections, mount this once in a host
 *  component and swap which Program renders inside onFrame -- see
 *  BackgroundHost (§6.4) instead of calling this per section. */
export function useGLCanvas(build: (gl: OGLRenderingContext, renderer: Renderer) => GLSetup | null, options: UseGLCanvasOptions = {}) {
  const renderScale = Math.min(1, Math.max(0.3, options.renderScale ?? 1));
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const wrapperRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    const wrapper = wrapperRef.current;
    if (!canvas || !wrapper) return;

    const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
    const lowPower = (navigator.hardwareConcurrency || 8) <= 4 || (navigator as Navigator & { connection?: { saveData?: boolean } }).connection?.saveData === true;

    let renderer: Renderer | null = null;
    let setup: GLSetup | null = null;
    let rafId: number | null = null;
    let lastFrameTime = 0;
    let isIntersecting = true;

    function init() {
      renderer = new Renderer({ canvas: canvas!, dpr: Math.min(window.devicePixelRatio || 1, MAX_DPR), alpha: true, antialias: false, powerPreference: "low-power" });
      setup = build(renderer.gl, renderer);
    }
    function resize() {
      if (!renderer || !wrapper || !canvas) return;
      const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
      const { width, height } = wrapper.getBoundingClientRect(); // measured on resize only
      if (width <= 0 || height <= 0) return;
      renderer.dpr = dpr * renderScale;
      renderer.setSize(width, height);
      if (renderScale < 1) { canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; }
      setup?.onResize?.(width, height, dpr); // CSS pixels, independent of render scale
    }
    function renderFrame(now: number) {
      if (!renderer || !setup) return;
      setup.onFrame({ gl: renderer.gl, renderer, time: now / 1000, width: renderer.gl.canvas.width, height: renderer.gl.canvas.height, dpr: renderer.dpr ?? 1 });
    }
    function loop(now: number) {
      rafId = requestAnimationFrame(loop);
      if (now - lastFrameTime < FRAME_INTERVAL_MS) return;
      lastFrameTime = now;
      renderFrame(now);
    }
    function startLoop() { if (rafId === null) rafId = requestAnimationFrame(loop); }
    function stopLoop() { if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } } // cancel, never "skip the draw"
    function syncLoop() {
      const shouldRun = isIntersecting && !document.hidden && !reducedMotionQuery.matches && !lowPower;
      shouldRun ? startLoop() : stopLoop();
    }

    if (!reducedMotionQuery.matches && !lowPower) { init(); resize(); renderFrame(performance.now()); }

    const resizeObserver = new ResizeObserver(() => {
      if (!renderer && !reducedMotionQuery.matches && !lowPower) { init(); renderFrame(performance.now()); }
      resize();
    });
    resizeObserver.observe(wrapper);

    const io = new IntersectionObserver(([entry]) => { isIntersecting = entry?.isIntersecting ?? true; syncLoop(); }, { threshold: 0 });
    io.observe(wrapper);

    const onVisibility = () => syncLoop();
    document.addEventListener("visibilitychange", onVisibility);

    const onReducedMotionChange = () => {
      if (reducedMotionQuery.matches) stopLoop();
      else if (!renderer) { init(); resize(); renderFrame(performance.now()); syncLoop(); }
      else syncLoop();
    };
    reducedMotionQuery.addEventListener("change", onReducedMotionChange);

    const onContextLost = (e: Event) => { e.preventDefault(); stopLoop(); };
    const onContextRestored = () => { init(); resize(); syncLoop(); };
    canvas.addEventListener("webglcontextlost", onContextLost, false);
    canvas.addEventListener("webglcontextrestored", onContextRestored, false);

    if (!reducedMotionQuery.matches && !lowPower) syncLoop();

    return () => {
      stopLoop();
      resizeObserver.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
      reducedMotionQuery.removeEventListener("change", onReducedMotionChange);
      canvas.removeEventListener("webglcontextlost", onContextLost);
      canvas.removeEventListener("webglcontextrestored", onContextRestored);
      setup?.onDispose?.();
      (renderer?.gl.getExtension("WEBGL_lose_context") as WEBGL_lose_context | null | undefined)?.loseContext();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return { canvasRef, wrapperRef };
}
```

### 6.2 `use-token-colors.ts` — colours re-tint per direction, resolved off the frame loop

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

/** Resolves void CSS custom properties (oklch(), color-mix(), var()) to
 *  [r,g,b] (0..1) for shader uniforms, and re-resolves on direction/theme
 *  change so a background re-tints instead of hardcoding colour. Shaders
 *  can't read oklch()/var() themselves -- this is the one legal place to
 *  touch the DOM for colour, resolved once per change, never per frame. */
export function useTokenColors<T extends Record<string, `--${string}`>>(names: T) {
  const values = useRef<Record<keyof T, [number, number, number]>>(
    Object.fromEntries(Object.keys(names).map((k) => [k, [0.5, 0.5, 0.5]])) as Record<keyof T, [number, number, number]>,
  );
  useEffect(() => {
    const probe = document.createElement("canvas").getContext("2d");
    if (!probe) return;
    function readAll() {
      const style = getComputedStyle(document.documentElement);
      for (const key of Object.keys(names) as (keyof T)[]) {
        const raw = style.getPropertyValue(names[key]).trim();
        if (!raw) continue;
        probe!.clearRect(0, 0, 1, 1);
        probe!.fillStyle = raw; // the browser parses oklch()/color-mix() for us
        probe!.fillRect(0, 0, 1, 1);
        const [r, g, b] = probe!.getImageData(0, 0, 1, 1).data;
        values.current[key] = [r! / 255, g! / 255, b! / 255];
      }
    }
    readAll();
    const mo = new MutationObserver(readAll); // theme toggle flips a class on <html>
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"] });
    const schemeQuery = window.matchMedia("(prefers-color-scheme: dark)");
    schemeQuery.addEventListener("change", readAll);
    return () => { mo.disconnect(); schemeQuery.removeEventListener("change", readAll); };
  }, [names]);
  return values;
}
```

### 6.3 `use-lazy-webgl.ts` — idle + intersection, skip entirely on low power

```ts
// src/components/fx/backgrounds/use-lazy-webgl.ts
"use client";
import { useEffect, useRef, useState } from "react";

/** Nothing WebGL imports before idle AND intersection, and never at all
 *  under prefers-reduced-motion, hardwareConcurrency<=4 or saveData (the
 *  poster stays). Call from a small per-effect "use client" wrapper --
 *  passing the loader as a prop across the server/client boundary isn't
 *  serialisable, so each effect owns its own lazy wrapper (see below). */
export function useLazyWebgl() {
  const wrapperRef = useRef<HTMLDivElement | null>(null);
  const [shouldLoad, setShouldLoad] = useState(false);
  useEffect(() => {
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const lowPower = (navigator.hardwareConcurrency || 8) <= 4 || (navigator as Navigator & { connection?: { saveData?: boolean } }).connection?.saveData === true;
    if (reduced || lowPower) return; // poster stays, nothing else loads
    const wrapper = wrapperRef.current;
    if (!wrapper) return;
    let idleId: number | undefined;
    const io = new IntersectionObserver(([entry]) => {
      if (!entry?.isIntersecting) return;
      io.disconnect();
      const request = "requestIdleCallback" in window ? window.requestIdleCallback : (cb: () => void) => setTimeout(cb, 1) as unknown as number;
      idleId = request(() => setShouldLoad(true)) as number;
    }, { rootMargin: "200px" });
    io.observe(wrapper);
    return () => { io.disconnect(); if (idleId !== undefined && "cancelIdleCallback" in window) window.cancelIdleCallback(idleId); };
  }, []);
  return { wrapperRef, shouldLoad };
}
```

```tsx
// src/components/fx/backgrounds/fluid-ether-lazy.tsx -- the wiring pattern for every effect below
"use client";
import dynamic from "next/dynamic";
import { useLazyWebgl } from "./use-lazy-webgl";

const FluidEther = dynamic(() => import("./fluid-ether").then((m) => m.FluidEther), { ssr: false });

export function FluidEtherLazy() {
  const { wrapperRef, shouldLoad } = useLazyWebgl();
  return (
    <div ref={wrapperRef} aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 isolate overflow-hidden bg-bg">
      <div className="absolute inset-0" style={{ background: "radial-gradient(60% 60% at 50% 40%, var(--brand-subtle), var(--bg) 70%)" }} />
      {shouldLoad && <FluidEther />}
    </div>
  );
}
// Same shape for ParticleFieldLazy, PixelDitherLazy, DisplacementGridLazy --
// swap the import and drop the poster gradient div where a flat --bg reads fine.
```

### 6.4 `background-host.tsx` — the actual "one context, several sections" pattern

```tsx
// src/components/fx/backgrounds/background-host.tsx
"use client";
/**
 * ONE shared WebGL context for an entire page of sections. Build every
 * section's look as its own Program/Mesh once at init (never rebuilt), then
 * each frame render only the Mesh for whichever section is `activeKey`. The
 * parent page owns an IntersectionObserver over its section elements (same
 * shape as the scroll-progress driver in references/3d-scenes.md) and
 * passes the winning section's key down; BackgroundHost never tears down or
 * recreates the GL context when that prop changes.
 *
 * Production note: mount this via next/dynamic(ssr:false) gated by
 * requestIdleCallback in the layout (it's page-level, not per-section, so
 * IntersectionObserver-gating like use-lazy-webgl.ts doesn't apply the same
 * way) -- the measurement in §7 loaded it eagerly for a clean comparison,
 * which is why its jsKb isn't a fair "lazy" number; defer it in production.
 */
import { Color, Mesh, Program, Renderer, Triangle } from "ogl";
import { useEffect, useRef } from "react";
import { useGLCanvas } from "./use-gl-canvas";
import { useTokenColors } from "./use-token-colors";

export type BackgroundKey = "fluid" | "dither" | "none";

// ... build one Program per look (reuse the fragment shaders from §7.1/§7.3,
// trimmed), keep them in a small map, and in onFrame:
//   const key = activeRef.current;
//   if (key === "none") return;          // context stays alive, nothing drawn
//   renderer.render({ scene: meshByKey[key] });
// Full worked file (two looks, fluid + dither): backgrounds-scratch/src/components/fx/backgrounds/background-host.tsx
```

```tsx
// usage: one IntersectionObserver over section refs drives activeKey
"use client";
import { useEffect, useRef, useState } from "react";
import { BackgroundHost, type BackgroundKey } from "@/components/fx/backgrounds/background-host";

export function SharedSections() {
  const [active, setActive] = useState<BackgroundKey>("fluid");
  const fluidRef = useRef<HTMLElement | null>(null);
  const ditherRef = useRef<HTMLElement | null>(null);
  useEffect(() => {
    const targets: [HTMLElement, BackgroundKey][] = [];
    if (fluidRef.current) targets.push([fluidRef.current, "fluid"]);
    if (ditherRef.current) targets.push([ditherRef.current, "dither"]);
    const io = new IntersectionObserver((entries) => {
      const top = entries.filter((e) => e.isIntersecting).sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
      const match = top && targets.find(([el]) => el === top.target);
      if (match) setActive(match[1]);
    }, { threshold: [0.3, 0.6] });
    for (const [el] of targets) io.observe(el);
    return () => io.disconnect();
  }, []);
  return (
    <>
      <BackgroundHost activeKey={active} />
      <section ref={fluidRef} className="relative isolate flex min-h-svh items-center">{/* … */}</section>
      <section ref={ditherRef} className="relative isolate flex min-h-svh items-center">{/* … */}</section>
    </>
  );
}
```

## 7. WebGL tier (T4): the four effects

Each follows the licence rule at the top of this doc: an original implementation of the
*technique*, never a transcription of any library's shader.

### 7.1 FluidEther — liquid/fluid distortion

Domain-warped value noise (Quilez-style "warp noise by noise", maths not code) advected by time
and pointer velocity. No fluid solver.

```tsx
// src/components/fx/backgrounds/fluid-ether.tsx
"use client";
import { Color, Program, Renderer, Triangle, Mesh } from "ogl";
import { useEffect, useRef } from "react";
import { useGLCanvas } from "./use-gl-canvas";
import { useTokenColors } from "./use-token-colors";

const vertex = /* glsl */ `
  attribute vec2 uv; attribute vec2 position; varying vec2 vUv;
  void main() { vUv = uv; gl_Position = vec4(position, 0.0, 1.0); }
`;
const fragment = /* glsl */ `
  precision highp float;
  varying vec2 vUv;
  uniform float uTime; uniform vec2 uResolution; uniform vec2 uPointer; uniform float uPointerStrength;
  uniform vec3 uColorA; uniform vec3 uColorB; uniform vec3 uColorC;

  float hash(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
  float valueNoise(vec2 p) {
    vec2 i = floor(p), f = fract(p);
    float a = hash(i), b = hash(i + vec2(1.0, 0.0)), c = hash(i + vec2(0.0, 1.0)), d = hash(i + vec2(1.0, 1.0));
    vec2 u = f * f * (3.0 - 2.0 * f);
    return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
  }
  float fbm(vec2 p) {
    // 3 octaves: 4 pushed desktop dropped frames over budget (measured, see table below).
    float sum = 0.0, amp = 0.5;
    for (int i = 0; i < 3; i++) { sum += amp * valueNoise(p); p *= 2.02; amp *= 0.55; }
    return sum;
  }
  void main() {
    float aspect = uResolution.x / uResolution.y;
    vec2 p = (vUv - 0.5) * vec2(aspect, 1.0) * 2.2;
    vec2 pointerOffset = (uPointer - 0.5) * vec2(aspect, 1.0) * 2.2 - p;
    float pointerDist = length(pointerOffset);
    p += pointerOffset * (0.18 * uPointerStrength) / (1.0 + pointerDist * pointerDist * 2.0);

    // Single-level domain warp: 2 fbm() calls for the warp vector + 1 to sample it, 3/pixel.
    float t = uTime * 0.045;
    vec2 warp = vec2(fbm(p + vec2(0.0, t)), fbm(p + vec2(5.2, -t)));
    float field = fbm(p + warp * 1.6);

    vec3 color = mix(uColorA, uColorB, smoothstep(0.25, 0.62, field));
    color = mix(color, uColorC, smoothstep(0.55, 0.92, field) * 0.6);
    color *= 0.55 + 0.45 * smoothstep(1.35, 0.2, length(p));
    gl_FragColor = vec4(color, 1.0);
  }
`;

export function FluidEther({ className = "" }: { className?: string }) {
  const colors = useTokenColors({ a: "--bg", b: "--brand", c: "--surface-raised" });
  const pointerRef = useRef({ x: 0.5, y: 0.5, target: 0 });

  const { canvasRef, wrapperRef } = useGLCanvas((gl) => {
    const geometry = new Triangle(gl);
    const program = new Program(gl, {
      vertex, fragment,
      uniforms: {
        uTime: { value: 0 }, uResolution: { value: [1, 1] }, uPointer: { value: [0.5, 0.5] }, uPointerStrength: { value: 0 },
        uColorA: { value: new Color(...colors.current.a) }, uColorB: { value: new Color(...colors.current.b) }, uColorC: { value: new Color(...colors.current.c) },
      },
    });
    const mesh = new Mesh(gl, { geometry, program });
    return {
      onResize: (w, h) => { program.uniforms.uResolution.value = [w, h]; },
      onFrame: ({ renderer, time }) => {
        program.uniforms.uTime.value = time;
        program.uniforms.uPointer.value = [pointerRef.current.x, pointerRef.current.y];
        pointerRef.current.target *= 0.94; // decays toward 0 when the pointer stops moving
        program.uniforms.uPointerStrength.value = pointerRef.current.target;
        const c = colors.current;
        (program.uniforms.uColorA.value as Color).set(...c.a);
        (program.uniforms.uColorB.value as Color).set(...c.b);
        (program.uniforms.uColorC.value as Color).set(...c.c);
        (renderer as Renderer).render({ scene: mesh });
      },
      onDispose: () => { geometry.remove(); program.remove(); },
    };
  }, { renderScale: 0.6 }); // full-bleed fragment shader: viewport-area-bound, not just DPR-bound

  useEffect(() => {
    const wrapper = wrapperRef.current;
    if (!wrapper) return;
    const onMove = (e: PointerEvent) => {
      const rect = wrapper.getBoundingClientRect();
      pointerRef.current.x = (e.clientX - rect.left) / rect.width;
      pointerRef.current.y = 1 - (e.clientY - rect.top) / rect.height;
      pointerRef.current.target = 1;
    };
    window.addEventListener("pointermove", onMove, { passive: true });
    return () => window.removeEventListener("pointermove", onMove);
  }, [wrapperRef]);

  return (
    <div ref={wrapperRef} aria-hidden="true" className={`pointer-events-none absolute inset-0 -z-10 isolate overflow-hidden bg-bg ${className}`}>
      <div className="absolute inset-0" style={{ background: "radial-gradient(65% 65% at 50% 40%, var(--brand-subtle), var(--bg) 70%)" }} />
      <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
    </div>
  );
}
```

**Measured verdict: hero-only, and re-verify before trusting broadly.** Even after DPR≤1.5, a
0.6 render scale and dropping from a double to a single domain warp (5→3 `fbm()` calls/pixel), this
is the one effect in the catalogue that did **not** hold the dropped-frame budget on the machine
used for this measurement (~15% both profiles, vs. the ≤2%/≤5% target — see §8). The other three
T4 effects held 0% cleanly and repeatably. Don't ship FluidEther as a secondary/ambient effect;
use it only as the page's single hero, and re-profile on a quiet runner (§8 explains the noise)
before relying on the exact percentage. If it still doesn't hold, drop to 2 octaves or lower
`TARGET_FPS` to 24 for this effect specifically before cutting it.

### 7.2 ParticleField — GPU-driven particle field (ballpit/snow energy)

Every particle's position is a closed-form function of time evaluated **in the vertex shader** —
zero per-frame CPU/JS work beyond the `uTime` uniform, no physics solver.

```tsx
// src/components/fx/backgrounds/particle-field.tsx
"use client";
import { Color, Geometry, Program, Renderer, Mesh } from "ogl";
import { useGLCanvas } from "./use-gl-canvas";
import { useTokenColors } from "./use-token-colors";

const COUNT = 220; // budget: cheap enough for 60fps-capable integrated GPUs at DPR<=1.5

const vertex = /* glsl */ `
  attribute vec3 aSeed; // x: horizontal start, y: fall phase, z: depth (0..1)
  uniform float uTime; uniform float uAspect;
  varying float vDepth;
  void main() {
    float depth = aSeed.z;
    float fallSpeed = 0.05 + depth * 0.10;
    float y = fract(aSeed.y - uTime * fallSpeed) * 2.4 - 1.2; // wraps top-to-bottom, never visibly resets
    float drift = sin(uTime * (0.15 + depth * 0.2) + aSeed.x * 20.0) * (0.05 + depth * 0.10);
    float x = (aSeed.x * 2.0 - 1.0) * uAspect + drift;
    vDepth = depth;
    gl_Position = vec4(x, y, 0.0, 1.0);
    gl_PointSize = (1.4 + depth * 3.2) * 2.0;
  }
`;
const fragment = /* glsl */ `
  precision highp float;
  varying float vDepth;
  uniform vec3 uColorNear; uniform vec3 uColorFar;
  void main() {
    vec2 c = gl_PointCoord - 0.5;
    float d = length(c);
    if (d > 0.5) discard;
    float alpha = smoothstep(0.5, 0.15, d) * (0.35 + vDepth * 0.55);
    gl_FragColor = vec4(mix(uColorFar, uColorNear, vDepth), alpha);
  }
`;

export function ParticleField({ className = "" }: { className?: string }) {
  const colors = useTokenColors({ near: "--brand", far: "--fg-subtle" });
  const { canvasRef, wrapperRef } = useGLCanvas((gl) => {
    const aSeed = new Float32Array(COUNT * 3);
    for (let i = 0; i < COUNT; i++) { aSeed[i * 3] = Math.random(); aSeed[i * 3 + 1] = Math.random(); aSeed[i * 3 + 2] = Math.random(); }
    const geometry = new Geometry(gl, { aSeed: { size: 3, data: aSeed } });
    const program = new Program(gl, {
      vertex, fragment,
      uniforms: { uTime: { value: 0 }, uAspect: { value: 1 }, uColorNear: { value: new Color(...colors.current.near) }, uColorFar: { value: new Color(...colors.current.far) } },
      transparent: true, depthTest: false,
    });
    const mesh = new Mesh(gl, { mode: gl.POINTS, geometry, program });
    return {
      onResize: (w, h) => { program.uniforms.uAspect.value = w / h; },
      onFrame: ({ renderer, time }) => {
        program.uniforms.uTime.value = time;
        const c = colors.current;
        (program.uniforms.uColorNear.value as Color).set(...c.near);
        (program.uniforms.uColorFar.value as Color).set(...c.far);
        (renderer as Renderer).render({ scene: mesh });
      },
      onDispose: () => { geometry.remove(); program.remove(); },
    };
  });
  return (
    <div ref={wrapperRef} aria-hidden="true" className={`pointer-events-none absolute inset-0 -z-10 isolate overflow-hidden bg-bg ${className}`}>
      <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
    </div>
  );
}
```

### 7.3 PixelDither — pixel grid + ordered (Bayer) dithering, snow-like falling flecks

A fragment-shader-only effect: no particle geometry, a 4x4 Bayer threshold matrix over a
per-cell fall function (decades-old, unowned ordered-dithering technique, written fresh here).

```tsx
// src/components/fx/backgrounds/pixel-dither.tsx
"use client";
import { Color, Program, Renderer, Triangle, Mesh } from "ogl";
import { useGLCanvas } from "./use-gl-canvas";
import { useTokenColors } from "./use-token-colors";

const vertex = /* glsl */ `
  attribute vec2 uv; attribute vec2 position; varying vec2 vUv;
  void main() { vUv = uv; gl_Position = vec4(position, 0.0, 1.0); }
`;
const fragment = /* glsl */ `
  precision highp float;
  varying vec2 vUv;
  uniform float uTime; uniform vec2 uResolution; uniform vec3 uColorBg; uniform vec3 uColorFg;
  float hash(vec2 p) { p = fract(p * vec2(153.34, 231.11)); p += dot(p, p + 27.1); return fract(p.x * p.y); }
  float bayer(vec2 cell) {
    int x = int(mod(cell.x, 4.0)); int y = int(mod(cell.y, 4.0)); int index = y * 4 + x;
    float m[16];
    m[0]=0.0; m[1]=8.0; m[2]=2.0; m[3]=10.0; m[4]=12.0; m[5]=4.0; m[6]=14.0; m[7]=6.0;
    m[8]=3.0; m[9]=11.0; m[10]=1.0; m[11]=9.0; m[12]=15.0; m[13]=7.0; m[14]=13.0; m[15]=5.0;
    for (int i = 0; i < 16; i++) { if (i == index) return m[i] / 16.0; }
    return 0.0;
  }
  void main() {
    float cellPx = 10.0;
    vec2 gridRes = floor(uResolution / cellPx);
    vec2 cell = floor(vUv * gridRes);
    float speed = 6.0 + hash(vec2(cell.x, 0.0)) * 10.0;
    float phase = hash(vec2(cell.x, 1.0)) * gridRes.y;
    float fallenRow = mod(phase - uTime * speed, gridRes.y);
    float streak = 1.0 - smoothstep(0.0, 6.0, abs(cell.y - fallenRow));
    float flicker = hash(cell + floor(uTime * 2.0));
    float brightness = streak * (0.55 + 0.45 * flicker);
    float dithered = step(bayer(cell), brightness);
    gl_FragColor = vec4(mix(uColorBg, uColorFg, dithered * brightness), 1.0);
  }
`;

export function PixelDither({ className = "" }: { className?: string }) {
  const colors = useTokenColors({ bg: "--bg", fg: "--brand" });
  const { canvasRef, wrapperRef } = useGLCanvas((gl) => {
    const geometry = new Triangle(gl);
    const program = new Program(gl, {
      vertex, fragment,
      uniforms: { uTime: { value: 0 }, uResolution: { value: [1, 1] }, uColorBg: { value: new Color(...colors.current.bg) }, uColorFg: { value: new Color(...colors.current.fg) } },
    });
    const mesh = new Mesh(gl, { geometry, program });
    return {
      onResize: (w, h) => { program.uniforms.uResolution.value = [w, h]; },
      onFrame: ({ renderer, time }) => {
        program.uniforms.uTime.value = time;
        const c = colors.current;
        (program.uniforms.uColorBg.value as Color).set(...c.bg);
        (program.uniforms.uColorFg.value as Color).set(...c.fg);
        (renderer as Renderer).render({ scene: mesh });
      },
      onDispose: () => { geometry.remove(); program.remove(); },
    };
  });
  return (
    <div ref={wrapperRef} aria-hidden="true" className={`pointer-events-none absolute inset-0 -z-10 isolate overflow-hidden bg-bg ${className}`}>
      <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
    </div>
  );
}
```

### 7.4 DisplacementGrid — a line grid that warps around the pointer

Every vertex's displacement is computed in the vertex shader from its base grid position, `uTime`
and `uPointer` — the CPU never touches per-frame geometry.

```tsx
// src/components/fx/backgrounds/displacement-grid.tsx
"use client";
import { Color, Geometry, Program, Renderer, Mesh } from "ogl";
import { useEffect, useRef } from "react";
import { useGLCanvas } from "./use-gl-canvas";
import { useTokenColors } from "./use-token-colors";

const COLS = 28, ROWS = 16;

const vertex = /* glsl */ `
  attribute vec2 aBase; // grid position in -1..1
  uniform float uTime; uniform float uAspect; uniform vec2 uPointer; uniform float uPointerStrength;
  void main() {
    vec2 p = aBase;
    float wave = sin(p.x * 3.1 + uTime * 0.4) * cos(p.y * 2.3 - uTime * 0.3) * 0.03;
    p.y += wave;
    p.x += sin(p.y * 4.0 + uTime * 0.25) * 0.02;
    vec2 pAspect = p * vec2(uAspect, 1.0);
    vec2 pointerAspect = uPointer * vec2(uAspect, 1.0);
    vec2 toPoint = pAspect - pointerAspect;
    float d = length(toPoint);
    float falloff = smoothstep(0.55, 0.0, d) * uPointerStrength;
    p += normalize(toPoint + 0.0001) * falloff * 0.12;
    gl_Position = vec4(p, 0.0, 1.0);
  }
`;
const fragment = /* glsl */ `
  precision highp float; uniform vec3 uColor;
  void main() { gl_FragColor = vec4(uColor, 0.5); }
`;

function buildGridLines(): Float32Array {
  const points: number[] = [];
  const stepX = 2 / (COLS - 1), stepY = 2 / (ROWS - 1);
  for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS - 1; c++) {
    const x0 = -1 + c * stepX, x1 = -1 + (c + 1) * stepX, y = -1 + r * stepY;
    points.push(x0, y, x1, y);
  }
  for (let c = 0; c < COLS; c++) for (let r = 0; r < ROWS - 1; r++) {
    const y0 = -1 + r * stepY, y1 = -1 + (r + 1) * stepY, x = -1 + c * stepX;
    points.push(x, y0, x, y1);
  }
  return new Float32Array(points);
}

export function DisplacementGrid({ className = "" }: { className?: string }) {
  const colors = useTokenColors({ line: "--line-strong" });
  const pointerRef = useRef({ x: 0, y: 0, target: 0 });
  const { canvasRef, wrapperRef } = useGLCanvas((gl) => {
    const geometry = new Geometry(gl, { aBase: { size: 2, data: buildGridLines() } });
    const program = new Program(gl, {
      vertex, fragment,
      uniforms: { uTime: { value: 0 }, uAspect: { value: 1 }, uPointer: { value: [0, 0] }, uPointerStrength: { value: 0 }, uColor: { value: new Color(...colors.current.line) } },
      transparent: true, depthTest: false,
    });
    const mesh = new Mesh(gl, { mode: gl.LINES, geometry, program });
    return {
      onResize: (w, h) => { program.uniforms.uAspect.value = w / h; },
      onFrame: ({ renderer, time }) => {
        program.uniforms.uTime.value = time;
        program.uniforms.uPointer.value = [pointerRef.current.x, pointerRef.current.y];
        pointerRef.current.target *= 0.95;
        program.uniforms.uPointerStrength.value = pointerRef.current.target;
        (program.uniforms.uColor.value as Color).set(...colors.current.line);
        (renderer as Renderer).render({ scene: mesh });
      },
      onDispose: () => { geometry.remove(); program.remove(); },
    };
  });
  useEffect(() => {
    const wrapper = wrapperRef.current;
    if (!wrapper) return;
    const onMove = (e: PointerEvent) => {
      const rect = wrapper.getBoundingClientRect();
      pointerRef.current.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
      pointerRef.current.y = (1 - (e.clientY - rect.top) / rect.height) * 2 - 1;
      pointerRef.current.target = 1;
    };
    window.addEventListener("pointermove", onMove, { passive: true });
    return () => window.removeEventListener("pointermove", onMove);
  }, [wrapperRef]);
  return (
    <div ref={wrapperRef} aria-hidden="true" className={`pointer-events-none absolute inset-0 -z-10 isolate overflow-hidden bg-bg ${className}`}>
      <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
    </div>
  );
}
```

## 8. Measured numbers

Measured 2026-09-18 with void's own CLI (`bun packages/cli/src/index.ts audit <url> --only
perf,smooth --profile both`) against a scratch Next 16.3.5 app built from `templates/next` (real
tokens, real header/footer, one section per effect), on real Chromium via Playwright.
`templates/next`'s baseline (no background) measured jsKb 140.0 — "added JS" below is the delta.

| Effect | Added JS (gzip) | Dropped frames mobile | Dropped frames desktop | Frame p95 | LCP | Holds budget (≤2%/≤5%, p95≤20ms)? |
|---|---|---|---|---|---|---|
| FluidEther | +20.1 KB | 0–21% (noisy, see below) | 15–29% (worst run 52.6%) | 16.7–33.4ms | 624ms | **No** — hero-only, re-verify |
| ParticleField | +19.4 KB | 0% | 0% | 16.7–16.8ms | 624ms | Yes, cleanly, every rerun |
| PixelDither | +19.7 KB | 0% | 0–2.3% | 16.7–16.8ms | 636ms | Yes |
| DisplacementGrid | +19.6 KB | 0% | 0% | 16.7–16.8ms | 636/72ms | Yes, cleanly, every rerun |
| BackgroundHost (2 looks, 1 ctx) | +17.6 KB combined | 0% | 0% | 16.7–16.8ms | 624/80ms | Yes (measured eager-loaded, see §6.4 note) |
| CSS tier, 3 sections stacked (mesh + dot-grid + grain) | +1.4 KB | 0% | 5.3% | 16.7–33.3ms | 636/116ms | Desktop borderline — see note |

`htmlKb` stayed 6.2–6.6 KB across every route (≤30KB gate, plenty of room).

**On the noise:** these were measured on a shared development workstation with other GPU-heavy
processes running (browsers, compositor), not a quiet CI runner — load average was consistently
above 2 during testing. Re-running FluidEther alone produced 6.7%, 29.2%, 52.6% and finally
15.4%/15.4% desktop dropped-frame readings across four separate runs at the same code, which is
too much variance to trust as an exact number. What *is* reproducible across every rerun: the
other three T4 effects and the shared-context host held 0% dropped frames on both profiles every
single time, while FluidEther never once measured 0% on desktop. That relative signal — one
full-bleed domain-warp shader costs meaningfully more than GPU-driven points or a flat
fragment-shader dither grid, at the same DPR cap and fps cap — is the real, reportable finding,
even though the exact percentage should be re-measured on a quiet runner (e.g. CI) before writing
it into a hard gate. This is also *why* §3 calls out render scale as a non-negotiable specifically
for full-bleed fragment shaders: DPR alone visibly wasn't enough for this one.

**On the CSS tier's desktop number:** 5.3% dropped frames there comes from three `filter: blur(60px)`
gradient-mesh layers animating `transform` while scrolling past two other sections in the same
pass — a real, useful finding (blurred compositor layers aren't free even though they're 0 KB JS)
but not representative of §2 rule 1 ("at most one live expensive background"): this demo
deliberately stacks three effects to show variety on one scroll pass. A single `GradientMesh`
instance, alone, is the intended real-world case and is expected to cost less; if you need to
verify, audit a page with just one instance.

### 8.1 The CSS tier is not free either — four findings from shipping it

Measured 2026-09-18 on void's own home page (`web/`), which already spends its one WebGL context
on a scroll-linked object. Every number is desktop dropped frames from `void smooth <url>
--profile desktop`, same machine, back to back. Baseline — the object, no section surfaces at
all — was **9.2%**.

| Configuration | Dropped frames | Delta |
|---|---|---|
| Baseline: object only, no surfaces | 9.2% | — |
| One surface (SVG-tile dot grid, masked, hero only) | 14.6% | +5.4 |
| One surface + its pointer light left at `opacity: 0` | 22.6% | +8.0 |
| Four surfaces, patterns as CSS gradients, masks on the animated layer | 51.8% | +42.6 |
| Four surfaces, patterns as CSS gradients, masks hoisted to the static parent | 45.0% | −6.8 |
| Four surfaces, patterns as CSS gradients, **animation removed entirely** | 44.1% | −0.9 |
| Four surfaces, patterns as **SVG `data:` tiles**, animated | 36.7% | −15.1 |

1. **A CSS gradient pattern is not a cheap texture.** `repeating-linear-gradient` with 1px stripes
   and `radial-gradient(circle at 1.4px 1.4px, …) / 34px 34px` are evaluated per pixel per raster
   tile, and a scrolling page rasterises new tiles constantly. The identical pattern written as a
   34×34 SVG `data:` URI — which the browser decodes once and blits — recovered **15 points**.
   Write section patterns as image tiles; keep gradients for one-off soft washes, not repeats.
2. **Moving the layer is nearly free; painting it is not.** Removing the tile animation entirely
   bought 0.9 points. The translate really does run on the compositor, exactly as intended — the
   cost was never the motion, and "make it static to make it fast" would have been the wrong fix
   applied to the wrong thing. Measure before you delete the part that looks expensive.
3. **`mask-image` belongs on the static parent, never on the layer that moves.** A mask on an
   animating element is re-evaluated as it moves: 6.8 points across four sections. Put the pattern
   and the animation on the child, the mask on the parent.
4. **`opacity: 0` does not mean "not painted".** A 544px soft radial gradient sitting invisible in
   the tree, waiting for a hover that had not happened, cost **8 points** on its own. Use
   `display: none` with `transition-behavior: allow-discrete` and `@starting-style` — the fade
   survives, the raster does not. This applies to every hidden decorative layer, not just this one.

**Budget, restated with numbers:** on a page that already has a WebGL object, **one** full-section
CSS surface is affordable (+5 points) and four are not (+27 even as image tiles). §2 rule 1 says
at most one expensive background page-wide; this is the same rule at the cheap tier, and the
reason void's own home page ships exactly one — the hero — while the inner routes, which run no
WebGL at all, each carry one in their page head.

## 9. What was cut, and why

- **A literal fluid/Navier–Stokes solver** — a real velocity-field advection simulation (what
  React Bits' Liquid Ether almost certainly runs) is expensive at any resolution and hard to keep
  inside a section's JS/frame budget from scratch; the domain-warped noise field in §7.1 evokes
  the same "liquid" feeling for a fraction of the cost, and even that needed a render-scale
  mitigation to get close to budget. A real solver would need to be WASM-compute-bound and is out
  of scope for a section background.
- **A second BackgroundHost look per extra section** — the pattern in §6.4 generalises to N looks
  (add a Program, add a case), but only 2 are worked through here to keep the reference reviewable;
  extending it is mechanical, not a new technique.
- **CPU-side particle physics** (collision, gravity accumulation) for ParticleField — a real
  ballpit has inter-particle collisions; that requires either a spatial hash updated per frame
  (CPU cost, violates "no per-frame allocation" at any interesting particle count) or a compute
  shader (WebGL2 transform feedback, meaningfully more code and a stricter browser floor). The
  closed-form GPU motion in §7.2 gets the "field of drifting particles" feeling without either.
- **Nothing was cut for licence reasons** — every effect the owner named (liquid/fluid, ballpit,
  pixel/snow, grid distortion) has a from-scratch equivalent above; none needed to be dropped.

## Verify

1. `void lint` on the project using these: no `lint/hardcoded-colors` (colours must come from
   `useTokenColors`, never a literal hex in a shader default), no `lint/raf-without-cancel`,
   no `lint/webgl-in-map`.
2. `bun run build`, then `void smooth --start "bun run start -p 3100" --port 3100`:
   `smooth/multiple-webgl-contexts` must be 0 even with several background sections on the page;
   `smooth/raf-loop-idle`, `smooth/reduced-motion-ignored`.
3. `void audit <url> --only perf,smooth --profile both --format md` per route with a background —
   compare `droppedFramePct`/`frameP95` against this doc's table, and re-measure on a quiet
   machine if the numbers look noisy (see §8).
4. Screenshot at 390/768/1440, light + dark, and `--force-prefers-reduced-motion`: every background
   must show a finished, on-token static frame, and body text over it must still read at 4.5:1
   (`void a11y`).

## References

- `references/3d-scenes.md` — the scroll-linked-object sibling of this doc: measured library
  costs (why OGL, not three.js), the `SceneCanvas`/render-on-demand pattern this doc's hooks share.
- `references/webgl-canvas.md` — the full WebGL/canvas hard-rule table and gate this doc applies.
- `components/references/recipes.md` — `PatternBackdrop`, `AuroraBackdrop`, `HeroSpotlight`: the
  T0/T1 recipes this doc's §5 cross-references rather than duplicates.
- `inspire/references/effect-downgrades.md` — the general "heavy effect → cheap equivalent"
  mapping table this doc specialises for backgrounds.
- Full working prototype (all files above, typechecked, built, measured):
  `/home/parth/code/sandbox/void/ui/v3/backgrounds-scratch/`.
