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
- 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 viaBackgroundHost(§6); they never mount two canvases. - 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. - 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.
craftrule 3 (one accent, rationed) extends here: rationing applies to motion, not just colour. - 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.
- 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 +
visibilitychangepause that cancels the rAF (not "skip the draw") — built intouse-gl-canvas.ts(§6.1); this is3d-scenes.mdrule 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 ofuTimeevaluated in the vertex shader, never CPU-side arrays rewritten per frame. - Lazy after idle + intersection —
use-lazy-webgl.ts(§6.3): nothing WebGL imports beforerequestIdleCallbackand 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, andconnection.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) — inuse-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:
// 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}`} />;
}/* 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:
- Grain — already in
packages/tokens/css/base.cssas thegrainutility (SVGfeTurbulence, opacity from--grain-opacity, 0 KB, procedural). Don't reimplement it. - Dot / line grid —
background-image: radial-gradient(...)tiled, masked to fade at the edges. Optional 40stransformdrift, paused under reduced motion. - Gradient mesh (aurora) — 2–3 blurred radial layers, one hue family
(
color-mix(in oklch, var(--brand) N%, transparent)), drifting viatransformonly (compositor-only, neverbackground-position). This is void'sAuroraBackdroprecipe (components/references/recipes.md) — same effect, cross-referenced here because it's the direct cheap answer to React Bits' Aurora/Silk. - Masked radial spotlight — pointer-follow glow, position written via
requestAnimationFrameinto a CSS custom property (never React state, never a per-pixel-move re-render). - 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).
// 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 }}
/>
);
}// 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>
);
}// 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}`} />;
}/* 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
// 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
// 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
// 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 };
}// 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
// 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// 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.
// 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.
// 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).
// 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.
// 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 ) 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 , 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 |
- A CSS gradient pattern is not a cheap texture.
repeating-linear-gradientwith 1px stripes andradial-gradient(circle at 1.4px 1.4px, …) / 34px 34pxare evaluated per pixel per raster tile, and a scrolling page rasterises new tiles constantly. The identical pattern written as a 34×34 SVGdata: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. - 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.
mask-imagebelongs 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.opacity: 0does 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. Usedisplay: nonewithtransition-behavior: allow-discreteand@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
void linton the project using these: nolint/hardcoded-colors(colours must come fromuseTokenColors, never a literal hex in a shader default), nolint/raf-without-cancel, nolint/webgl-in-map.bun run build, thenvoid smooth --start "bun run start -p 3100" --port 3100:smooth/multiple-webgl-contextsmust be 0 even with several background sections on the page;smooth/raf-loop-idle,smooth/reduced-motion-ignored.void auditper route with a background — compare--only perf,smooth --profile both --format md droppedFramePct/frameP95against this doc's table, and re-measure on a quiet machine if the numbers look noisy (see §8).- 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), theSceneCanvas/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/.