Skip to content

Reference · motion

Section backgrounds: alive surfaces that stay fast

motion/references/backgrounds.md1051 linesupdated 16 Sept 2026

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 pageBackgroundHost (§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 + intersectionuse-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 gridbackground-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 CSSanimation-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 --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 --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 --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.mdPatternBackdrop, 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/.