Voronoi Cells
A slowly flowing Voronoi diagram on one canvas — organic cell walls, tinted mosaic or separated pebbles, seeded so the same field comes back every time.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/voronoi-cells.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "VoronoiCells" component — a slowly
flowing Voronoi diagram painted on one canvas, used as a hero or card
backdrop. Its only dependency is a cn() class merger (clsx + tailwind-merge).
Contract
- export function VoronoiCells(props): props extend React.ComponentProps<"div">
(rest props spread onto the root) plus:
- count?: number (default 140) — seed points across the PADDED field, not
the visible box. The effective count is at most
min(count, floor(cssArea / 500), 900) and is then rounded DOWN to a whole
stratified lattice. Roughly a quarter of the sites sit just outside the
frame on purpose (see "padding" below).
- seed?: number (default 3) — integer seed. Same seed, same field, forever.
- variant?: "outline" | "mosaic" | "pebbles" (default "outline").
- speed?: number (default 1) — multiplier on the flow; 0 freezes the field
AND the rAF loop never starts (not "runs and paints the same frame").
- jitter?: number (default 0.65), 0..1 — how far a site's resting position
may sit from the centre of its stratum. 0 gives near-uniform cells, 1
gives slivers. Be honest in the docs that the orbit adds another ~18% of
a stratum on top, so jitter 0 is not a perfectly straight lattice.
- intensity?: number (default 1), 0..1 — scales every alpha. Deliberately
NOT allowed above 1: this component's job is to sit under copy.
- strokeWidth?: number (default 1), clamped 0.25..4 — wall width in CSS px.
- tone?: "foreground" | "muted" | "primary" (default "muted").
- fade?: boolean (default true) — radial mask dissolving toward the edges.
- children render above the canvas; className merges onto the root via cn().
- "use client": canvas, rAF and observers.
- Clamp every numeric prop up front and treat non-finite as the default. NaN
count would build an empty field; jitter above 1 would let sites escape
their stratum and invalidate the clipper's neighbour bound below.
Behavior — geometry (this is the part worth getting right)
- Sites are STRATIFIED, not uniform random: exactly one site per cell of an
nx x ny lattice (Worley-style), placed at guard + jitter*hash inside its
own stratum. Uniform random points make a Voronoi field of slivers next to
giants; one-per-stratum makes it organic but evenly sized, and it bounds
how far a cell's neighbours can be, which is what makes the clipper cheap.
- Padding: inflate the container by ~1.15 mean spacings on every side and lay
the lattice over THAT. Cells are clipped against the padded rectangle, so
the rectangle's own edges fall outside the visible box and no wall ever
lands on the container border (a Voronoi clipped to the exact box draws a
frame around your hero).
- A cell is built by starting from the padded rectangle and clipping it with
one perpendicular bisector per nearby site (Sutherland-Hodgman against the
half-plane of points at least as close to p as to q). The intersection of
those half-planes IS the Voronoi cell — no Fortune sweep, no Delaunay
library.
- Neighbours are visited in square rings around the site's own stratum, and
the walk stops as soon as the next ring cannot reach the polygon: a site r
rings away is at least (r-1)*stratum - 2*slack away and its bisector sits
at half of that, so once that beats the farthest current vertex, no wider
ring can cut anything. Make the bound EXACT (keep the factor 2 and the
slack term) rather than eyeballing it — a sloppier bound is invisible at
the default jitter and starts leaving overlapping cells at jitter 1.
`slack` is how far a site can leave its own stratum, i.e.
max(0, wanderAmplitude - (1 - jitter) / 2) * max(sx, sy).
- Flow: positions are RECOMPUTED from (stratum, hash, t) every frame, never
integrated. Each site orbits its resting point on two sines with hashed
phases and rates (~57s a lap). Nothing accumulates, so the field cannot
drift apart, a frozen field is bit-identical to frame 0 of a moving one,
and a resize only stretches the pattern instead of reshuffling it.
- Track, per polygon edge, WHICH neighbour's bisector produced it. Then emit
each interior wall only from the lower-indexed of the two cells that share
it: half the stroke work, and the wall is composited at exactly one alpha
instead of two overlapping strokes compounding into a darker line.
Behavior — drawing a frame
- Batch: one Path2D per quantised tint level (6 levels) plus one Path2D for
every wall, so a whole frame is 6 fills and 1 stroke regardless of count.
Per-cell fill calls are the obvious way to write this and cost 900 draw
calls a frame instead of 7.
- outline strokes walls only; mosaic fills cells at hash-picked alphas and
strokes the walls at ~55% of the outline alpha; pebbles fills each cell
scaled to 86% of the way toward its own vertex mean (a convex polygon's
mean is inside it, so stones never invert or spill into a neighbour) and
draws no walls at all.
- Ink: the canvas carries the tone token as an inline `color`; read
getComputedStyle(canvas).color back and assign the string straight to
fillStyle/strokeStyle, with alpha on globalAlpha. Never hand-parse a
colour — passing the computed string through means oklch(), color-mix()
and any rebranded token all work.
- Per-tone alpha ceiling (foreground 0.30, muted 0.50, primary 0.30) — this
is load-bearing, not decoration. --foreground and --primary are near-black
in light and near-white in dark; painted at full strength, text-foreground
laid over a wall measures 2.0-2.6:1. With the ceiling the worst measured
pixel anywhere under a text block is 12.2:1 in light and 10.2:1 in dark.
- Do NOT put --border in the tone table: in the shadcn dark theme it is
oklch(1 0 0 / 10%), so every alpha would land ~10x fainter in dark.
Behavior — sizing, power and lifecycle
- A ResizeObserver observes the CANVAS (not the root, whose padding would
offset the box); its first callback is the initial sizing. Try
observe(canvas, {box: "device-pixel-content-box"}) inside try/catch —
browsers that do not know that box throw a WebIDL TypeError from observe()
rather than ignoring it — and fall back to observe(canvas).
- Backing store scale = min(2, max(1, devicePixelRatio, deviceBox / cssWidth)).
Take the MAX of the two APIs: emulated surfaces report a 1:1 device-pixel
box while the page renders at 2x, and real 2x windows have been measured
reporting devicePixelRatio 1 next to an honest 2x box. Either one alone
ships a canvas that is blurry in one of the two environments. Re-apply
ctx.setTransform after every resize (writing canvas.width resets the
context) and derive the scale from the actual backing size.
- Because the canvas is absolute inset-0 size-full, writing width/height
touches only the backing store and can never feed a new layout size back
into the ResizeObserver — no feedback loop.
- The rAF loop runs only when an IntersectionObserver says the canvas is on
screen, document.visibilityState is "visible", motion is allowed and
speed > 0. dt is clamped to 1/30s so a backgrounded tab cannot jump the
field on resume.
- Theme flips: a MutationObserver on <html> (class/style/data-theme) re-reads
the ink, repaints the still frame when the loop is paused, and schedules
ONE more read ~400ms later, because tokens animated with transition-colors
report an interpolated colour for a few hundred ms.
- prefers-reduced-motion: reduce — read via useSyncExternalStore (server
snapshot false, so it is hydration-safe) and keep it in the effect deps.
Under reduce the loop never starts and exactly one static frame is painted:
the whole field, just a still one, never a blank box.
- Cleanup on unmount: cancelAnimationFrame, both observers, the
MutationObserver, the settle timeout, and set canvas.width/height to 0 so a
full-size bitmap is not pinned to a detached element until GC runs.
Rendering & styling
- Semantic tokens only, zero colour literals: the ink is var(--foreground) /
var(--muted-foreground) / var(--primary) resolved through the canvas's own
computed style, so light/dark and any rebranded palette come for free.
- Root is "relative isolate overflow-hidden" holding (a) the canvas —
aria-hidden, pointer-events-none, absolute inset-0 AND size-full, because
an absolutely positioned replaced element with inset-0 alone renders at its
intrinsic 300x150 — and (b) a "relative z-10" wrapper for children.
- Accessibility: the canvas is pure decoration — aria-hidden, no tabindex, no
pointer events, so it never takes focus and never swallows a click.
Customization levers
- Density and cost: `count` is the cost knob and it is linear — measured
medians at 1200x600 CSS on a 2x backing store are 0.4ms (200 sites), 1.1ms
(500), 2.3ms (1000), 4.0ms (1500) for the costliest variant. MAX_SITES
(900) and MIN_AREA_PER_SITE (500 CSS px^2) are the two safety valves;
raise MAX_SITES if you only ship to desktop, lower MIN_AREA_PER_SITE for
denser small cards.
- Look: `jitter` is the organic-vs-uniform axis; PEBBLE_SCALE is the gutter
width between stones; ALPHA_BUCKETS (6) is how many distinct tints the
mosaic uses — raise it for a smoother wash at the cost of more fill calls.
- Motion: BASE_RATE sets the lap time (~57s at 0.11 rad/s) and WANDER the
orbit radius as a fraction of a stratum. Raising WANDER past ~0.25 starts
letting sites cross stratum boundaries, so raise `slack` with it.
- Readability: `intensity` is the volume knob you hand to consumers;
TONE_SCALE is the ceiling you set for them. If you add a tone, measure the
worst composited pixel under real copy before shipping it, do not eyeball
it — the wall alpha and the token's own lightness multiply.
- Palette: add a tone entry pointing at any token — var(--chart-2) for a
branded field, var(--primary-foreground) for an inverted panel.
- Shape: MARGIN_RATIO controls how far outside the frame the lattice extends;
below ~1 the clip rectangle starts showing up as a straight line along an
edge.Concepts
- Half-plane clipping — a cell is the padded rectangle cut by one perpendicular bisector per nearby site. The intersection of those half-planes is the Voronoi cell, so the whole diagram needs no Fortune sweep and no Delaunay dependency — just a Sutherland-Hodgman clip that a convex polygon survives with at most one extra vertex per cut.
- Stratified sites (Worley lattice) — exactly one seed point per cell of an
nx × nylattice, jittered inside its own stratum. Uniform random points give a Voronoi field of slivers next to giants; one-per-stratum keeps the cells evenly sized and bounds how far a neighbour can be, which is what makes the clipper's early exit possible. - Exact ring bound — neighbours are visited in square rings and the walk stops when
(r−1)·stratum − 2·slack > 2·maxVertexDist, because a bisector sits at half the distance between two sites. The bound is exact, not a heuristic, so cost per cell stays flat ascountgrows: measured 1.9 µs/site at 209 sites and 2.3 µs/site at 1512. - Padded clip rectangle — the lattice is laid over the container inflated by ~1.15 mean spacings, so the rectangle every cell is clipped against sits off screen. Clip a Voronoi to the exact container instead and its outermost walls collapse onto the border, drawing a frame around your hero.
- Edge ownership — every polygon edge records which neighbour's bisector made it, so each shared wall is stroked by exactly one of the two cells. That halves the stroke work and keeps the wall at a single alpha; stroking it from both sides composites it into a visibly darker line.
- Recomputed, not integrated — positions come from
(stratum, hash, t)each frame rather than accumulating velocity. Nothing drifts,speed={0}is bit-identical to frame 0 of a moving field, and a resize stretches the pattern instead of reshuffling it. - Intensity as a readability knob —
intensityscales every alpha and is capped at 1, and each tone carries its own ceiling on top. Without those ceilings a wall painted at full--foregroundmeasures 2.0:1 againsttext-foregroundlaid over it; with them the worst composited pixel under a text block measures 12.2:1 in light and 10.2:1 in dark.
Circuit Board
A seamlessly tiling PCB backdrop — seeded traces with 45°/90° corners, pads, vias and component footprints, plus an optional signal pulse.
Bokeh Lights
Slowly floating out-of-focus light discs on one canvas — seeded for a reproducible picture, blitted from a pre-rendered sprite instead of a per-frame blur.