"use client";
import * as React from "react";
export type VoronoiFill = "none" | "tint" | "distance";
export type VoronoiPalette = "ink" | "clay" | "sea" | "accent";
export interface VoronoiFieldProps extends React.ComponentPropsWithoutRef<"div"> {
/** Number of drifting seed points (8–120). */
count?: number;
/** Drift speed multiplier; 0 holds the mosaic still (the pointer seed still works). */
speed?: number;
/** Cell edge width, in px. */
lineWidth?: number;
/** Cell fills: none, a flat tint per cell, or a radial falloff from each seed. */
fill?: VoronoiFill;
/** Fill palette. `ink` follows the theme, `accent` uses the accent colour. */
palette?: VoronoiPalette;
/** The pointer becomes a seed with its own cell, and nearby seeds make room for it. */
pointer?: boolean;
/** Draw a small dot at every seed. */
showSeeds?: boolean;
/** Colour of the pointer's cell and the edges around it (any CSS colour). */
accent?: string;
}
type RGB = [number, number, number];
/* ---------------------------------- Colour --------------------------------- */
let probe: CanvasRenderingContext2D | null = null;
/** Resolves any CSS colour (hex, rgb(), oklch()…) to RGB by painting it on a 1×1 canvas. */
function resolveColor(input: string, fallback: RGB): RGB {
probe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
if (!probe) return fallback;
probe.clearRect(0, 0, 1, 1);
probe.fillStyle = "rgba(0, 0, 0, 0)";
probe.fillStyle = input; // invalid colours are ignored → transparent → fallback
probe.fillRect(0, 0, 1, 1);
const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
return a === 0 ? fallback : [r, g, b];
}
const luminance = ([r, g, b]: RGB) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
/** On light themes (dark ink), very light colours are pulled toward the ink so they stay visible. */
function legible(color: RGB, ink: RGB): RGB {
if (luminance(ink) > 0.5 || luminance(color) < 0.62) return color;
return color.map((v, i) => Math.round(v + (ink[i] - v) * 0.45)) as RGB;
}
/** Re-reads colours when the theme changes: class/data-theme on <html> or a scoped [data-theme] ancestor. */
function observeTheme(el: Element, onChange: () => void) {
const mo = new MutationObserver(onChange);
const options = { attributes: true, attributeFilter: ["class", "data-theme"] };
mo.observe(document.documentElement, options);
const scoped = el.parentElement?.closest("[data-theme]");
if (scoped && scoped !== document.documentElement) mo.observe(scoped, options);
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", onChange);
return () => {
mo.disconnect();
mq.removeEventListener("change", onChange);
};
}
/* ----------------------------- Reduced motion ------------------------------ */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* ------------------------------ Triangulation ------------------------------ */
interface Triangle {
a: number;
b: number;
c: number;
/** Circumcentre — a Voronoi vertex. */
cx: number;
cy: number;
/** Squared circumradius. */
r2: number;
}
function makeTriangle(xs: Float64Array, ys: Float64Array, a: number, b: number, c: number): Triangle {
const ax = xs[a];
const ay = ys[a];
const bx = xs[b] - ax;
const by = ys[b] - ay;
const cx = xs[c] - ax;
const cy = ys[c] - ay;
const d = 2 * (bx * cy - by * cx) || 1e-9;
const b2 = bx * bx + by * by;
const c2 = cx * cx + cy * cy;
const ux = (cy * b2 - by * c2) / d;
const uy = (bx * c2 - cx * b2) / d;
return { a, b, c, cx: ax + ux, cy: ay + uy, r2: ux * ux + uy * uy };
}
/**
* Bowyer–Watson Delaunay triangulation. Points are inserted one at a time into a huge super
* triangle: every triangle whose circumcircle contains the new point is removed, and the hole's
* boundary edges are re-joined to the point. O(n²) — plenty for a few hundred points per frame.
* `xs`/`ys` must have three spare slots at the end for the super triangle.
*/
function triangulate(xs: Float64Array, ys: Float64Array, count: number, span: number): Triangle[] {
const s = count;
xs[s] = -span * 10;
ys[s] = -span * 10;
xs[s + 1] = span * 20;
ys[s + 1] = -span * 10;
xs[s + 2] = span * 5;
ys[s + 2] = span * 20;
let tris: Triangle[] = [makeTriangle(xs, ys, s, s + 1, s + 2)];
const edges = new Map<number, number>();
for (let p = 0; p < count; p++) {
const px = xs[p];
const py = ys[p];
edges.clear();
const keep: Triangle[] = [];
for (const t of tris) {
const dx = px - t.cx;
const dy = py - t.cy;
if (dx * dx + dy * dy < t.r2) {
// Bad triangle: record its edges; edges seen twice are interior to the hole.
for (const [u, v] of [
[t.a, t.b],
[t.b, t.c],
[t.c, t.a],
]) {
const key = u < v ? u * 4096 + v : v * 4096 + u;
edges.set(key, (edges.get(key) ?? 0) + 1);
}
} else keep.push(t);
}
for (const [key, seen] of edges) {
if (seen === 1) keep.push(makeTriangle(xs, ys, Math.floor(key / 4096), key % 4096, p));
}
tris = keep;
}
return tris;
}
/* --------------------------------- Helpers --------------------------------- */
const hash = (n: number) => {
const s = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return s - Math.floor(s);
};
const PALETTES: Record<Exclude<VoronoiPalette, "ink" | "accent">, string[]> = {
clay: ["#C8553D", "#E09F6B", "#D8C3A5", "#8C5A3C", "#F0C987"],
sea: ["#1F6F78", "#3FA7A3", "#B6F3D1", "#5C8EA8", "#8FC9B9"],
};
const GHOSTS = 10; // far points on a ring, so every real cell is a closed polygon
const STILL_TIME = 4; // seconds of drift used for the reduced-motion still frame
/**
* An animated Voronoi mosaic on one canvas. Seeds drift and bounce inside the container; every
* frame they are triangulated with Bowyer–Watson (plus a far ring of ghost points so edge cells
* close), and each cell is the polygon of circumcentres around its seed. All edges are stroked as
* a single path, so shared edges never double up. The pointer becomes a seed with its own accent
* cell, nearby seeds are pushed aside, and edges near it glow in the accent colour.
*/
export function VoronoiField({
count = 36,
speed = 0.5,
lineWidth = 1,
fill = "distance",
palette = "clay",
pointer = true,
showSeeds = true,
accent = "#FF7448",
className = "",
children,
...props
}: VoronoiFieldProps) {
const reduceMotion = usePrefersReducedMotion();
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
const n = Math.max(8, Math.min(120, Math.round(count)));
const drift = Math.max(0, speed);
const interactive = pointer && !reduceMotion;
const total = n + 1 + GHOSTS; // seeds, pointer slot, ghosts
let width = 0;
let height = 0;
let dpr = 1;
let raf = 0;
let last = 0;
let time = 0;
let inView = false;
let ink: RGB = [24, 24, 27];
let accentRGB: RGB = [255, 116, 72];
let colours: RGB[] = [];
// Seed state: position, heading and a decaying push from the pointer.
const sx = new Float64Array(n);
const sy = new Float64Array(n);
const heading = Float64Array.from({ length: n }, (_, i) => hash(i + 3) * Math.PI * 2);
const pace = Float64Array.from({ length: n }, (_, i) => 0.6 + hash(i + 9) * 0.8);
const pushX = new Float64Array(n);
const pushY = new Float64Array(n);
// Triangulation input (+3 slots for the super triangle).
const xs = new Float64Array(total + 3);
const ys = new Float64Array(total + 3);
const ptr = { clientX: 0, clientY: 0, x: 0, y: 0, inside: false, touch: false, strength: 0 };
const readTheme = () => {
ink = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);
accentRGB = legible(resolveColor(accent, [255, 116, 72]), ink);
colours =
palette === "ink"
? [ink]
: palette === "accent"
? [accentRGB]
: PALETTES[palette].map((c) => legible(resolveColor(c, ink), ink));
};
/** Low-discrepancy (R2 sequence) start positions: even coverage without randomness. */
const seed = () => {
for (let i = 0; i < n; i++) {
sx[i] = ((0.5 + (i + 1) * 0.7548776662) % 1) * width;
sy[i] = ((0.5 + (i + 1) * 0.5698402910) % 1) * height;
}
};
const step = (dt: number) => {
time += dt;
const cellSize = Math.sqrt((width * height) / n);
const reach = cellSize * 1.4;
const cruise = cellSize * 0.18 * drift; // px per second
for (let i = 0; i < n; i++) {
// Gentle wander: each heading turns slowly back and forth.
heading[i] += Math.sin(time * 0.35 + i * 1.7) * dt * 0.5;
let vx = Math.cos(heading[i]) * cruise * pace[i];
let vy = Math.sin(heading[i]) * cruise * pace[i];
if (ptr.strength > 0.01) {
const dx = sx[i] - ptr.x;
const dy = sy[i] - ptr.y;
const d = Math.sqrt(dx * dx + dy * dy) || 1;
if (d < reach) {
const f = (1 - d / reach) ** 2 * ptr.strength * cellSize * 6;
pushX[i] += (dx / d) * f * dt;
pushY[i] += (dy / d) * f * dt;
}
}
const decay = Math.exp(-dt * 2.5);
pushX[i] *= decay;
pushY[i] *= decay;
vx += pushX[i];
vy += pushY[i];
sx[i] += vx * dt;
sy[i] += vy * dt;
// Bounce off the walls by turning the heading around.
if (sx[i] < 0 || sx[i] > width) {
sx[i] = Math.min(width, Math.max(0, sx[i]));
heading[i] = Math.PI - heading[i];
pushX[i] *= -0.5;
}
if (sy[i] < 0 || sy[i] > height) {
sy[i] = Math.min(height, Math.max(0, sy[i]));
heading[i] = -heading[i];
pushY[i] *= -0.5;
}
}
};
const draw = () => {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
if (!width || !height) return;
// Assemble points: seeds, the pointer seed (if active), then the ghost ring.
let m = 0;
for (let i = 0; i < n; i++) {
xs[m] = sx[i];
ys[m] = sy[i];
m++;
}
const pointerIndex = ptr.strength > 0.05 ? m : -1;
if (pointerIndex >= 0) {
xs[m] = ptr.x;
ys[m] = ptr.y;
m++;
}
const real = m;
const span = Math.max(width, height);
for (let g = 0; g < GHOSTS; g++) {
const a = (g / GHOSTS) * Math.PI * 2;
xs[m] = width / 2 + Math.cos(a) * span * 2.5;
ys[m] = height / 2 + Math.sin(a) * span * 2.5;
m++;
}
const tris = triangulate(xs, ys, m, span);
// Each real point's cell = circumcentres of its triangles, sorted by angle around it.
const around: number[][] = Array.from({ length: real }, () => []);
tris.forEach((t, k) => {
if (t.a < real) around[t.a].push(k);
if (t.b < real) around[t.b].push(k);
if (t.c < real) around[t.c].push(k);
});
const cellSize = Math.sqrt((width * height) / n);
const dark = luminance(ink) > 0.5;
const edges = new Path2D();
let pointerCell: Path2D | null = null;
for (let i = 0; i < real; i++) {
const list = around[i];
if (list.length < 3) continue;
const px = xs[i];
const py = ys[i];
list.sort((p, q) => Math.atan2(tris[p].cy - py, tris[p].cx - px) - Math.atan2(tris[q].cy - py, tris[q].cx - px));
const poly = new Path2D();
list.forEach((k, j) => (j === 0 ? poly.moveTo(tris[k].cx, tris[k].cy) : poly.lineTo(tris[k].cx, tris[k].cy)));
poly.closePath();
edges.addPath(poly);
if (i === pointerIndex) {
pointerCell = poly;
continue;
}
if (fill === "none") continue;
const [r, g, b] = colours[Math.floor(hash(i + 17) * colours.length)];
// Colours read muddier on dark grounds, so they get a little more opacity there.
const base = palette === "ink" ? 0.05 + hash(i + 29) * 0.09 : (0.14 + hash(i + 29) * 0.3) * (dark ? 1.35 : 1);
let near = 0;
if (ptr.strength > 0.01) {
const d = Math.hypot(px - ptr.x, py - ptr.y) / (cellSize * 2.6);
near = d < 1 ? (1 - d) * (1 - d) * ptr.strength : 0;
}
if (fill === "tint") {
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${Math.min(0.85, base * (1 + near * 1.4)).toFixed(3)})`;
} else {
// Distance: a radial falloff from the seed toward the cell's far corners.
const grad = ctx.createRadialGradient(px, py, 0, px, py, cellSize * 1.15);
grad.addColorStop(0, `rgba(${r}, ${g}, ${b}, ${Math.min(0.9, (base + 0.12) * (1 + near * 1.6)).toFixed(3)})`);
grad.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);
ctx.fillStyle = grad;
}
ctx.fill(poly);
}
const [ir, ig, ib] = ink;
const [ar, ag, ab] = accentRGB;
ctx.lineJoin = "round";
ctx.lineWidth = lineWidth;
ctx.strokeStyle = `rgba(${ir}, ${ig}, ${ib}, ${dark ? 0.24 : 0.22})`;
ctx.stroke(edges);
if (ptr.strength > 0.02) {
// Edges near the pointer pick up the accent.
const glow = ctx.createRadialGradient(ptr.x, ptr.y, 0, ptr.x, ptr.y, cellSize * 2.4);
glow.addColorStop(0, `rgba(${ar}, ${ag}, ${ab}, ${(0.9 * ptr.strength).toFixed(3)})`);
glow.addColorStop(1, `rgba(${ar}, ${ag}, ${ab}, 0)`);
ctx.strokeStyle = glow;
ctx.stroke(edges);
}
if (pointerCell) {
ctx.fillStyle = `rgba(${ar}, ${ag}, ${ab}, ${(0.16 * ptr.strength).toFixed(3)})`;
ctx.fill(pointerCell);
ctx.lineWidth = lineWidth + 0.75;
ctx.strokeStyle = `rgba(${ar}, ${ag}, ${ab}, ${ptr.strength.toFixed(3)})`;
ctx.stroke(pointerCell);
}
if (showSeeds) {
const dots = new Path2D();
const r = Math.max(1, lineWidth * 1.1);
for (let i = 0; i < n; i++) {
dots.moveTo(sx[i] + r, sy[i]);
dots.arc(sx[i], sy[i], r, 0, Math.PI * 2);
}
ctx.fillStyle = `rgba(${ir}, ${ig}, ${ib}, ${dark ? 0.5 : 0.45})`;
ctx.fill(dots);
}
if (pointerIndex >= 0) {
ctx.fillStyle = `rgba(${ar}, ${ag}, ${ab}, ${ptr.strength.toFixed(3)})`;
ctx.beginPath();
ctx.arc(ptr.x, ptr.y, 2.5, 0, Math.PI * 2);
ctx.fill();
}
};
const frame = (now: number) => {
raf = 0;
const dt = Math.min(0.064, last ? (now - last) / 1000 : 1 / 60);
last = now;
if (ptr.inside) {
const rect = canvas.getBoundingClientRect();
ptr.x = ptr.clientX - rect.left;
ptr.y = ptr.clientY - rect.top;
}
ptr.strength += ((ptr.inside ? 1 : 0) - ptr.strength) * (1 - Math.pow(0.004, dt));
if (!ptr.inside && ptr.strength < 0.005) ptr.strength = 0;
step(dt);
draw();
const pushing = pushX.some((v) => Math.abs(v) > 0.05) || pushY.some((v) => Math.abs(v) > 0.05);
if (drift > 0 || ptr.inside || ptr.strength > 0 || pushing) schedule();
};
const schedule = () => {
if (!raf && inView && !reduceMotion && !document.hidden) raf = requestAnimationFrame(frame);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
last = 0;
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
if (Math.abs(rect.width - width) < 1 && Math.abs(rect.height - height) < 1 && width > 0) return;
const first = width === 0 || height === 0;
const kx = first ? 1 : rect.width / width;
const ky = first ? 1 : rect.height / height;
width = rect.width;
height = rect.height;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
if (first) {
seed();
if (reduceMotion) {
// A still frame from a few seconds into the drift reads less like a grid.
for (let k = 0; k < STILL_TIME * 30; k++) step(1 / 30);
}
} else {
for (let i = 0; i < n; i++) {
sx[i] *= kx;
sy[i] *= ky;
}
}
draw();
schedule();
};
const onThemeChange = () => {
readTheme();
if (!raf) draw();
};
const locate = (e: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
ptr.clientX = e.clientX;
ptr.clientY = e.clientY;
return e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
};
const onPointerMove = (e: PointerEvent) => {
if (e.pointerType === "touch" && !ptr.touch) return;
ptr.inside = locate(e);
if (ptr.inside) schedule();
};
const onPointerDown = (e: PointerEvent) => {
if (e.pointerType !== "touch") return;
ptr.touch = true;
ptr.inside = locate(e);
if (ptr.inside) schedule();
};
const onPointerUp = (e: PointerEvent) => {
if (e.pointerType !== "touch") return;
ptr.touch = false;
ptr.inside = false;
schedule();
};
const onPointerLeave = () => {
ptr.inside = false;
schedule();
};
const onVisibility = () => (document.hidden ? stop() : schedule());
const listen = (on: boolean) => {
if (on) {
window.addEventListener("pointermove", onPointerMove, { passive: true });
window.addEventListener("pointerdown", onPointerDown, { passive: true });
window.addEventListener("pointerup", onPointerUp, { passive: true });
window.addEventListener("pointercancel", onPointerUp, { passive: true });
document.documentElement.addEventListener("pointerleave", onPointerLeave);
} else {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerUp);
document.documentElement.removeEventListener("pointerleave", onPointerLeave);
}
};
// Pointer tracking lives on the window (only while visible), so content layered above the
// mosaic never blocks it and the mosaic never blocks the content.
const io = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
if (inView) {
if (interactive) listen(true);
schedule();
} else {
listen(false);
ptr.inside = false;
ptr.strength = 0;
stop();
}
});
readTheme();
resize();
const ro = new ResizeObserver(resize);
ro.observe(canvas);
io.observe(canvas);
const unobserveTheme = observeTheme(canvas, onThemeChange);
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
ro.disconnect();
io.disconnect();
unobserveTheme();
listen(false);
document.removeEventListener("visibilitychange", onVisibility);
};
}, [count, speed, lineWidth, fill, palette, pointer, showSeeds, accent, reduceMotion]);
return (
<div className={`absolute inset-0 overflow-hidden ${className}`} {...props}>
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 block h-full w-full text-zinc-900 dark:text-zinc-100"
/>
{children != null && <div className="relative h-full w-full">{children}</div>}
</div>
);
}