An ambient ASCII background of Voronoi cell WALLS only — ink appears exactly where the first and second nearest sites are equidistant, so a sparse, slowly drifting network of hairlines and clean three-way junctions sits on an otherwise empty field, and the pointer is a weighted site whose territory visibly swells.
npx shadcn add https://design.helpmarq.com /r/background-ascii-voronoi-walls.jsonregistry/core/background-ascii-voronoi-walls/component.tsx"use client";
import { useEffect, useRef } from "react";
// ---------------------------------------------------------------------------
// VoronoiWalls — an ambient ASCII background that draws a Voronoi diagram's
// WALLS ONLY. For every grid cell the nearest and second-nearest site distances
// d1 <= d2 are found by a plain linear scan, and ink is placed only where the
// NORMALIZED difference w = (d2 - d1) / (d2 + d1) falls below a small
// threshold — i.e. exactly where the two closest sites are near-equidistant.
// The normalization is the point: raw (d2 - d1) would make walls near the
// sites hairline and walls far away fat, whereas w is scale-free, so every
// boundary in the frame is the same weight. Cell interiors have w near 1 and
// draw nothing, so most of the field is empty by construction and what is left
// is a network of hairlines meeting at clean three-way junctions — three sites
// equidistant is a Y-junction, which is why foam and cracked mud look like
// this. Measured coverage is ~10% of cells, stable across drift and frame
// size. The pointer joins the diagram as an EXTRA SITE WITH A POWER WEIGHT
// (a Laguerre / power Voronoi cell): its distance is measured as the tangent
// length sqrt(d^2 - r^2) rather than d, with r^2 easing up while the pointer
// is over the canvas. A positive weight makes its cell OWN MORE TERRITORY, so
// the surrounding walls bow convexly away and the neighbours are squeezed —
// a bubble inflating in a foam. On leave r^2 eases back to 0, the cell
// collapses, and the walls spring back to the undisturbed diagram.
// ---------------------------------------------------------------------------
const RAMP = " .:-=+*#%@";
const ALPHA_BUCKETS = 6;
const WALL_W = 0.055; // normalized (d2-d1)/(d2+d1) below which a cell is inked
const WALL_POW = 1.5; // luminance falloff across the wall's width
const JUNCTION_BOOST = 1.6; // triple points read a touch brighter, as in a foam
const DRIFT_AMP = 0.045; // * min(W,H) — lissajous amplitude per site
const DRIFT_MIN = 0.06; // rad/s
const DRIFT_MAX = 0.17; // rad/s
const MIN_SEP = 0.16; // * min(W,H) — poisson-disk rejection radius
const POINTER_R = 0.1; // * min(W,H) — max power radius of the pointer's cell
const POINTER_TAU = 0.5; // s — weight ease time constant, both directions
const DT_MAX = 0.05;
const SEED = 0x5eed1a;
/** deterministic PRNG so the site layout is stable for a given size */
function mulberry32(a: number) {
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export interface VoronoiWallsProps {
/** grid cell size in px */
cellSize?: number;
/** base number of drifting sites, scaled by frame area and clamped 14..34 */
siteCount?: number;
className?: string;
}
export function VoronoiWalls({
cellSize = 12,
siteCount = 22,
className = "",
}: VoronoiWallsProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const reduced = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
let fg = "currentColor";
let cellW = cellSize;
let cellH = cellSize;
let cols = 0;
let rows = 0;
let width = 0;
let height = 0;
let sized = false;
let ready = false;
let disposed = false;
let charBuf = new Uint8Array(0);
const bucketLists: number[][] = Array.from(
{ length: ALPHA_BUCKETS },
() => []
);
// site fields: base position, lissajous frequency and phase
let n = 0;
let sx0 = new Float32Array(0);
let sy0 = new Float32Array(0);
let swx = new Float32Array(0);
let swy = new Float32Array(0);
let spx = new Float32Array(0);
let spy = new Float32Array(0);
let amp = 0;
// live positions, plus one trailing slot for the pointer site
let px = new Float32Array(0);
let py = new Float32Array(0);
const readTokens = () => {
fg = getComputedStyle(canvas).color;
};
const measureCell = (fontFamily: string) => {
const off = document.createElement("canvas");
const octx = off.getContext("2d");
if (!octx) return;
octx.font = `${cellSize}px ${fontFamily}`;
cellW = Math.max(4, octx.measureText("MMMMMMMMMM").width / 10);
cellH = cellSize;
};
/** poisson-disk rejection sampling — no two sites closer than minSep */
const seedSites = () => {
const m = Math.min(width, height);
const scale = Math.sqrt((width * height) / (1280 * 720));
n = Math.max(14, Math.min(34, Math.round(siteCount * scale)));
amp = DRIFT_AMP * m;
sx0 = new Float32Array(n);
sy0 = new Float32Array(n);
swx = new Float32Array(n);
swy = new Float32Array(n);
spx = new Float32Array(n);
spy = new Float32Array(n);
px = new Float32Array(n + 1);
py = new Float32Array(n + 1);
const rng = mulberry32(SEED);
let sep = MIN_SEP * m;
const pad = -amp * 0.5; // let sites drift a little off-frame
let placed = 0;
let fails = 0;
while (placed < n) {
const cx = pad + rng() * (width - 2 * pad);
const cy = pad + rng() * (height - 2 * pad);
let ok = true;
for (let i = 0; i < placed; i++) {
const dx = cx - sx0[i]!;
const dy = cy - sy0[i]!;
if (dx * dx + dy * dy < sep * sep) {
ok = false;
break;
}
}
if (!ok) {
// relax the separation rather than spin forever in a tight frame
if (++fails > 220) {
sep *= 0.9;
fails = 0;
}
continue;
}
sx0[placed] = cx;
sy0[placed] = cy;
swx[placed] = DRIFT_MIN + rng() * (DRIFT_MAX - DRIFT_MIN);
swy[placed] = DRIFT_MIN + rng() * (DRIFT_MAX - DRIFT_MIN);
spx[placed] = rng() * Math.PI * 2;
spy[placed] = rng() * Math.PI * 2;
placed++;
}
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
if (rect.width < 2 || rect.height < 2) {
sized = false;
return;
}
width = rect.width;
height = rect.height;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const fontFamily = getComputedStyle(canvas).fontFamily;
measureCell(fontFamily);
ctx.font = `${cellSize}px ${fontFamily}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
cols = Math.max(4, Math.ceil(width / cellW));
rows = Math.max(4, Math.ceil(height / cellH));
charBuf = new Uint8Array(cols * rows);
seedSites();
sized = true;
};
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
const onResize = () => {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
resizeTimer = null;
resize();
if (reduced) draw(0, 0, 0, 0);
}, 150);
};
const draw = (t: number, pxc: number, pyc: number, pr2: number) => {
if (!sized) return;
ctx.clearRect(0, 0, width, height);
for (let b = 0; b < ALPHA_BUCKETS; b++) bucketLists[b]!.length = 0;
// advance every site along its own slow lissajous
for (let i = 0; i < n; i++) {
px[i] = sx0[i]! + amp * Math.sin(t * swx[i]! + spx[i]!);
py[i] = sy0[i]! + amp * Math.cos(t * swy[i]! + spy[i]!);
}
const hasPointer = pr2 > 1;
const count = hasPointer ? n + 1 : n;
if (hasPointer) {
px[n] = pxc;
py[n] = pyc;
}
let i = 0;
for (let gy = 0; gy < rows; gy++) {
const y = gy * cellH + cellH / 2;
for (let gx = 0; gx < cols; gx++, i++) {
const x = gx * cellW + cellW / 2;
// three smallest effective distances by linear scan. Ordering is
// done on the SQUARED quantity and only the three winners are
// square-rooted, which keeps the scan at one sqrt-free inner step.
let q1 = Infinity;
let q2 = Infinity;
let q3 = Infinity;
for (let s = 0; s < count; s++) {
const dx = x - px[s]!;
const dy = y - py[s]!;
let q = dx * dx + dy * dy;
// power (Laguerre) distance for the weighted pointer site: the
// squared tangent length to its circle, so the bisector is the
// radical axis and the cell genuinely grows with the weight
if (hasPointer && s === n) q = q > pr2 ? q - pr2 : 0;
if (q < q1) {
q3 = q2;
q2 = q1;
q1 = q;
} else if (q < q2) {
q3 = q2;
q2 = q;
} else if (q < q3) {
q3 = q;
}
}
const d1 = Math.sqrt(q1);
const d2 = Math.sqrt(q2);
const d3 = Math.sqrt(q3);
const sum = d1 + d2;
const w = sum > 1e-6 ? (d2 - d1) / sum : 1;
let v = 0;
if (w < WALL_W) {
v = Math.pow(1 - w / WALL_W, WALL_POW);
const sum3 = d1 + d3;
if (sum3 > 1e-6 && (d3 - d1) / sum3 < WALL_W) {
v = Math.min(1, v * JUNCTION_BOOST);
}
}
const ci = Math.floor(v * (RAMP.length - 1));
charBuf[i] = ci;
if (ci !== 0) {
const bucket = Math.min(
ALPHA_BUCKETS - 1,
Math.floor(v * ALPHA_BUCKETS)
);
bucketLists[bucket]!.push(i);
}
}
}
ctx.fillStyle = fg;
for (let b = 0; b < ALPHA_BUCKETS; b++) {
const list = bucketLists[b]!;
if (list.length === 0) continue;
ctx.globalAlpha = 0.14 + (b / (ALPHA_BUCKETS - 1)) * 0.86;
for (let k = 0; k < list.length; k++) {
const idx = list[k]!;
const gx = idx % cols;
const gy = (idx - gx) / cols;
ctx.fillText(
RAMP[charBuf[idx]!]!,
gx * cellW + cellW / 2,
gy * cellH + cellH / 2
);
}
}
ctx.globalAlpha = 1;
};
// -- hot-path state -------------------------------------------------------
let raf = 0;
let last = 0;
let t = 0;
const ptr = { x: 0, y: 0, has: false, r2: 0 };
const loop = (now: number) => {
const dt = last ? Math.min(DT_MAX, (now - last) / 1000) : 1 / 60;
last = now;
t += dt;
const m = Math.min(width, height);
const target = ptr.has ? (POINTER_R * m) ** 2 : 0;
ptr.r2 += (target - ptr.r2) * Math.min(1, dt / POINTER_TAU);
draw(t, ptr.x, ptr.y, ptr.r2);
if (!document.hidden) raf = requestAnimationFrame(loop);
};
const onPointerMove = (e: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
ptr.x = e.clientX - rect.left;
ptr.y = e.clientY - rect.top;
ptr.has = true;
};
const onPointerLeave = () => {
ptr.has = false;
};
const onVis = () => {
if (!document.hidden && !reduced && ready) {
// a frame requested while the tab was hidden is still queued; drop it
// so resuming never leaves two loops running at double speed
cancelAnimationFrame(raf);
last = 0;
raf = requestAnimationFrame(loop);
}
};
const mo = new MutationObserver(() => {
readTokens();
if (reduced) draw(0, 0, 0, 0);
});
mo.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
document.fonts.ready.then(() => {
if (disposed) return;
readTokens();
resize();
ready = true;
if (reduced) {
draw(0, 0, 0, 0);
} else {
raf = requestAnimationFrame(loop);
}
});
window.addEventListener("resize", onResize);
if (!reduced) {
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerleave", onPointerLeave);
}
document.addEventListener("visibilitychange", onVis);
return () => {
disposed = true;
cancelAnimationFrame(raf);
if (resizeTimer) clearTimeout(resizeTimer);
mo.disconnect();
window.removeEventListener("resize", onResize);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerleave", onPointerLeave);
document.removeEventListener("visibilitychange", onVis);
};
}, [cellSize, siteCount]);
return (
<canvas
ref={canvasRef}
aria-hidden
className={`block h-full w-full font-mono text-foreground ${className}`}
/>
);
}
Pick background-ascii-voronoi-walls when the hero should read as a partition of space — cracked mud, soap foam, a territory map — sparse hairline boundaries meeting at clean three-way junctions, with the pointer owning a cell that inflates and squeezes its neighbours. background-lloyd-relax is the other Voronoi-derived piece and the easiest to confuse with this one: it draws the SITES as a migrating blue-noise dot screen and never inks a boundary at all, so pick it when the subject is tone built from dot spacing and pick this one when the subject is the boundary network itself. background-ascii-domain-walls is the better pick when those boundaries should be rough, thermally wandering walls that pinch off and reconnect rather than clean straight partitions; background-ascii-nodal-lines is the better pick when the curved hairlines should read as wave cancellation between emitters rather than as ownership boundaries; background-ascii-caustics for bright focused filaments from multiplied plane waves with the pointer as a converging lens; background-ascii-wake when the pointer's own trail, not a standing structure, is the subject.
Build <VoronoiWalls cellSize? siteCount? className?> as a full-bleed <canvas>. SITES: siteCount sites (default 22) scaled by sqrt(area / (1280*720)) and clamped to 14..34, positioned once per resize by mulberry32-seeded (seed 0x5eed1a) Poisson-disk rejection sampling at a minimum separation of 0.16 * min(W,H) so no two walls ever crowd each other; the rejection radius relaxes by 10% after 220 consecutive failures so a narrow frame terminates instead of spinning. Each site then drifts on its own slow Lissajous, p = p0 + A * [sin(t*wx + px), cos(t*wy + py)], with A = 0.045 * min(W,H) and wx, wy drawn per site from [0.06, 0.17] rad/s — a calm resting drift whose only ambient event is the topology occasionally flipping as two sites pass each other and a wall reconnects. FIELD: per grid cell the nearest, second-nearest and third-nearest site distances d1 <= d2 <= d3 are found by a plain linear scan over the site list (22 sites x cols*rows is trivial at cellSize 12) — no Delaunay, no library, no dependency; ordering is done on the squared quantity and only the three winners are square-rooted. WALL FUNCTION, which is the sparsity mechanism and must be the NORMALIZED difference rather than raw d2 - d1 (raw would make walls near a site hairline and walls far away fat, so thickness would vary wildly across the frame): w = (d2 - d1) / (d2 + d1), ink only where w < 0.055, luminance = pow(1 - w/0.055, 1.5). Cell interiors sit at w near 1 and draw absolutely nothing, so roughly 90% of the frame is empty by construction (measured 10.1% inked at 1280x720, stable across drift and across frame sizes from a 420x300 card to 2560x1400) and what remains is a network of hairlines meeting at clean triple junctions — three sites equidistant is exactly a Y-junction, which is why foam, cracked mud and a territory map all look like this. JUNCTION EMPHASIS: the third-nearest distance is carried through the same scan, and if (d3 - d1) / (d3 + d1) is also below 0.055 the luminance is multiplied by 1.6 and clamped to 1, so triple points read a touch brighter exactly as they do in a real foam. The sites themselves are never drawn as dots; only the walls, which is what keeps the read honest. POINTER: the pointer joins the diagram as an EXTRA SITE WITH A POWER WEIGHT — a Laguerre (power) Voronoi cell, which is the physically correct way for one site to own more territory than its neighbours. Its distance is measured as the tangent length sqrt(d^2 - r^2) instead of d, with r^2 eased from 0 toward (0.10 * min(W,H))^2 with a 0.5s time constant while the pointer is over the canvas. A positive weight moves every bisector it touches from a perpendicular line to a radical axis, so the surrounding walls bow convexly AWAY from the cursor and the neighbouring cells are visibly squeezed — a bubble inflating in a foam, not a stamp or a local recolor. On pointerleave r^2 eases back to 0 over the same time constant, the pointer's cell collapses to nothing, and the walls spring back to the undisturbed diagram. Direct-DOM rAF, zero React state on the hot path. Rendering is two-pass: pass one evaluates every cell into a Uint8Array ramp-index buffer and buckets each inked cell by luminance into one of 6 alpha buckets; pass two sets ctx.globalAlpha once per bucket and draws only that bucket's cells from the shared ' .:-=+*#%@' density ramp — never one globalAlpha write per cell. Ink is read once via getComputedStyle(canvas).color and re-derived on a documentElement class MutationObserver so theme flips are live in both light and dark; no colour literal anywhere. Mono cell width is measured from an offscreen canvas's measureText('MMMMMMMMMM')/10, and resize is debounced 150ms with the site layout reseeded deterministically. prefers-reduced-motion renders exactly one static frame at t = 0 with the pointer weight at 0, and skips the rAF loop and the pointer listeners entirely. The loop pauses on document.hidden and resumes on visibilitychange. The canvas is aria-hidden — it is decoration, and carries no content a screen reader should announce. Props: cellSize (grid cell px, default 12), siteCount (base site count before area scaling and the 14..34 clamp, default 22), className.