Backgrounds

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/**
 * `outline` strokes the cell walls, `mosaic` tints whole cells at seeded
 * alphas, `pebbles` shrinks every cell toward its own centre so the field
 * reads as separated stones with gutters between them.
 */
export type VoronoiCellsVariant = "outline" | "mosaic" | "pebbles"

export type VoronoiCellsTone = "foreground" | "muted" | "primary"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/voronoi-cells.json

Prompt

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 × ny lattice, 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 as count grows: 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 knobintensity scales every alpha and is capped at 1, and each tone carries its own ceiling on top. Without those ceilings a wall painted at full --foreground measures 2.0:1 against text-foreground laid over it; with them the worst composited pixel under a text block measures 12.2:1 in light and 10.2:1 in dark.

On This Page