Voronoi
Edges are derived from the SECOND nearest seed, not from thresholding the first. The set of points equidistant from two seeds is exactly the cell boundary, so tracking both distances gives borders that are mathematically correct and uniformly thin — thresholding the nearest distance alone fattens borders around large cells and loses them in small ones. Seeds live on a hashed 3x3 lattice neighbourhood rather than a uniform array, so the pattern is infinite and costs nine hashes per pixel however much of it is on screen.
Usage
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { Voronoi } from "@/components/voronoi"; <Voronoi />Customise
The component
components/craft/2d/Voronoi.tsx
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { useEffect, useRef } from "react";import { createPointerSource } from "../lib/pointerSource"; /** * A shattered-glass cell pattern that drifts, with the pointer as one of the * seeds. * * Voronoi is normally computed by finding the nearest seed. The edges come * from also tracking the SECOND nearest and drawing where the two distances * are close — the set of points equidistant from two seeds is exactly the cell * boundary. Deriving edges that way makes them mathematically correct and * uniformly thin, where thresholding the nearest distance alone gives borders * that fatten around large cells and vanish in small ones. * * Seeds live on a 3x3 neighbourhood of a hashed lattice rather than in a * uniform array, so the pattern is infinite and costs nine hashes per pixel * regardless of how much of it is on screen. */ const VERTEX = `attribute vec2 position;void main() { gl_Position = vec4(position, 0.0, 1.0); }`; const FRAGMENT = `precision highp float;uniform vec2 uResolution;uniform float uTime;uniform vec2 uPointer;uniform vec3 uColorA;uniform vec3 uColorB;uniform float uScale;uniform float uEdge; vec2 hash(vec2 p) { p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3))); return fract(sin(p) * 43758.5453);} void main() { vec2 uv = gl_FragCoord.xy / uResolution; float aspect = uResolution.x / uResolution.y; vec2 p = vec2(uv.x * aspect, uv.y) * uScale; vec2 cell = floor(p); vec2 local = fract(p); float nearest = 8.0; float second = 8.0; for (int y = -1; y <= 1; y++) { for (int x = -1; x <= 1; x++) { vec2 offset = vec2(float(x), float(y)); vec2 seed = hash(cell + offset); /* Each seed orbits its own cell, so the tessellation stays valid — a seed wandering out of its cell would break the neighbourhood search. */ seed = 0.5 + 0.42 * sin(uTime * 0.6 + 6.2831 * seed); float d = length(offset + seed - local); if (d < nearest) { second = nearest; nearest = d; } else if (d < second) { second = d; } } } /* The pointer is an extra seed, competing with the lattice. */ vec2 pointerP = vec2(uPointer.x * aspect, uPointer.y) * uScale; float dp = length(p - pointerP); if (dp < nearest) { second = nearest; nearest = dp; } else if (dp < second) { second = dp; } /* Equidistance from the two nearest seeds IS the boundary. */ float edge = smoothstep(0.0, 0.06 * uEdge, second - nearest); vec3 colour = mix(uColorA, uColorB, clamp(nearest * 0.9, 0.0, 1.0)); gl_FragColor = vec4(colour * edge, 1.0);}`; function compile(gl: WebGLRenderingContext, type: number, src: string) { const s = gl.createShader(type); if (!s) return null; gl.shaderSource(s, src); gl.compileShader(s); return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null;} function toRgb(hex: string): [number, number, number] { const m = /^#?([\da-f]{6})$/i.exec(hex.trim()); if (!m) return [0, 0, 0]; const n = parseInt(m[1], 16); return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];} export interface VoronoiProps { colorA?: string; colorB?: string; /** Cells across the shorter axis. */ scale?: number; /** Boundary thickness. */ edge?: number; speed?: number; still?: boolean; className?: string;} export function Voronoi({ colorA = "#c3f53c", colorB = "#14301f", scale = 7, edge = 1, speed = 1, still = false, className = "",}: VoronoiProps) { const ref = useRef<HTMLCanvasElement>(null); useEffect(() => { const canvas = ref.current; if (!canvas) return; const gl = canvas.getContext("webgl", { alpha: false, antialias: false }); if (!gl) return; const vs = compile(gl, gl.VERTEX_SHADER, VERTEX); const fs = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT); if (!vs || !fs) return; const program = gl.createProgram(); if (!program) return; gl.attachShader(program, vs); gl.attachShader(program, fs); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return; gl.useProgram(program); const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); const position = gl.getAttribLocation(program, "position"); gl.enableVertexAttribArray(position); gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0); const uniform = (n: string) => gl.getUniformLocation(program, n); const uResolution = uniform("uResolution"); const uTime = uniform("uTime"); const uPointer = uniform("uPointer"); gl.uniform3fv(uniform("uColorA"), toRgb(colorA)); gl.uniform3fv(uniform("uColorB"), toRgb(colorB)); gl.uniform1f(uniform("uScale"), scale); gl.uniform1f(uniform("uEdge"), edge); const reduced = still || (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches); let uw = 0; let uh = 0; const resize = () => { const dpr = Math.min(devicePixelRatio || 1, 2); const w = Math.floor(canvas.clientWidth * dpr); const h = Math.floor(canvas.clientHeight * dpr); if (!w || !h || (w === uw && h === uh)) return; uw = w; uh = h; canvas.width = w; canvas.height = h; gl.viewport(0, 0, w, h); gl.uniform2f(uResolution, w, h); }; const pointer = createPointerSource(canvas); const start = performance.now(); const draw = (now: number) => { resize(); const elapsed = (now - start) / 1000; const local = pointer.read(elapsed); gl.uniform2f(uPointer, local.x, local.y); gl.uniform1f(uTime, elapsed * speed); gl.drawArrays(gl.TRIANGLES, 0, 3); }; let frame = 0; const loop = (now: number) => { draw(now); frame = requestAnimationFrame(loop); }; draw(reduced ? start + 2600 : start); if (!reduced) frame = requestAnimationFrame(loop); const observer = new ResizeObserver(() => { resize(); if (reduced) draw(start + 2600); }); observer.observe(canvas); return () => { cancelAnimationFrame(frame); observer.disconnect(); pointer.dispose(); gl.deleteProgram(program); gl.deleteShader(vs); gl.deleteShader(fs); gl.deleteBuffer(buffer); }; }, [colorA, colorB, scale, edge, speed, still]); return <canvas ref={ref} aria-hidden className={`block h-full w-full ${className}`} />;} One file. Paste it in, delete what you do not need, change what you do. The "use client" directive is stripped above — add it back if you are on the Next.js App Router.
What it costs
- Weight
+2.5KB gzipped · no new dependencies
- Runs on
GPU
Work happens in a shader. The main thread only issues draw calls, so JavaScript stays free, but it costs GPU time and battery on mobile.
- Reduced motion
One composed frame with the cells mid-drift, and no pointer seed.
- Accessibility
- Canvas is aria-hidden; decorative.
- No focusable elements.
- On a coarse pointer the extra seed follows a slow automatic path, so the pattern still moves on a phone.
- Watch out
- Nine hashes plus nine distance tests per pixel. The heaviest of the 2D set, though still far cheaper than the 3D ones.
- Needs WebGL; renders nothing without it.