ns-ui
Honeycomb Draw
An ambient background where wax cells start as loosely packed circles and locally relax, wall by wall, into a tight hexagonal comb: each neighbour pair's shared wall straightens on its own schedule once the circles overlap, so a cell can show straight facets and still-round ones at once, with new circles seeding in from the left edge and a periodic recap flash keeping the finished lattice alive at rest.
Use when Pick honeycomb-draw when the backdrop should read as cells being actively formed — round wax tubes visibly flattening into hexagon walls one facet at a time, never a static pattern. Pick background-truchet-weave instead for an ambient tile field whose motif is a continuous interlaced arc weave rather than a growth/relaxation process, or grid-magnetic-lattice for a lattice whose motion is field-driven point attraction rather than cell-wall straightening.
Install
npx shadcn add https://design.helpmarq.com /r/honeycomb-draw.jsonSource
registry/core/honeycomb-draw/component.tsx"use client";
import { useEffect, useId, useRef } from "react";
// ---------------------------------------------------------------------------
// HoneycombDraw — an ambient background built on real comb formation: bees
// deposit wax as loosely packed circular tubes at near-uniform spacing, and
// each three-way wall junction relaxes toward 120 degrees purely through
// LOCAL wall-by-wall straightening as neighbouring circles overlap — no cell
// ever "decides" to be a hexagon (Pirk et al. 2004; Bauer & Bienefeld 2013).
//
// This is deliberately NOT a hex grid that fades in. Every cell tracks its
// own growing radius plus, independently, a per-neighbour wall spring: a
// wall starts relaxing only once that specific pair of circles overlaps by
// more than a threshold, and each wall settles on its own schedule. A cell's
// silhouette is sampled at 36 angles as an interpolation between its current
// circular radius and each neighbour-facing wall's target distance, weighted
// by that wall's own spring progress — so a cell can show three straight
// facets and three still-round ones at once, which is the visible signature
// that distinguishes this from a static grid whose opacity is ramping.
// ---------------------------------------------------------------------------
export interface HoneycombDrawProps {
/** Freezes on the fully-relaxed SETTLED_COMB frame without unmounting. */
paused?: boolean;
className?: string;
style?: React.CSSProperties;
}
function parseHex(raw: string): [number, number, number] | null {
const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(raw.trim());
if (!m) return null;
let h = m[1];
if (h.length === 3) h = h.split("").map((c) => c + c).join("");
const n = parseInt(h, 16);
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}
function rgbaCss([r, g, b]: [number, number, number], a: number): string {
return `rgba(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)},${a})`;
}
function lerpRGB(
a: [number, number, number],
b: [number, number, number],
t: number
): [number, number, number] {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
// Owner feedback on the first build: "it needs to be faster" — the 14s fill
// at 0.6 cells/s and the 4-7s recap gap left the surface looking static most
// of the time. Growth, seeding and the post-fill idle are all pushed up
// substantially here; the per-wall spring is also faster (SPRING_K raised)
// but the relaxation itself stays a real, watchable multi-frame transition —
// only the SEED_RATE / GROWTH_SECONDS / recap-gap changed the pace at which
// new relaxations are triggered, never the "walls straighten, not fade"
// mechanic itself.
const GROWTH_SECONDS = 0.9;
const RADIUS_FRAC = 0.62; // of spacing s
const OVERLAP_TRIGGER_FRAC = 0.06; // of s
const SPRING_K = 170;
const SPRING_DAMP = 1.0; // critical
const SEED_RATE = 6; // cells/s
const FILL_CAP = 0.85;
const RECAP_MIN = 0.6;
const RECAP_MAX = 1.6;
const RECAP_FLASH = 0.22;
const RECAP_FADE = 0.6;
const SAMPLES = 36; // boundary samples per cell, for the polygon outline
// Standard "odd-r" offset-triangular-lattice neighbour offsets — six
// directions per cell, three shared with the row above and three with the
// row below, so the whole field tiles without gaps.
const NEI_EVEN: [number, number][] = [
[1, 0],
[-1, 0],
[0, -1],
[1, -1],
[0, 1],
[1, 1],
];
const NEI_ODD: [number, number][] = [
[1, 0],
[-1, 0],
[-1, -1],
[0, -1],
[-1, 1],
[0, 1],
];
interface Wall {
neighbor: Cell | null;
triggered: boolean;
progress: number; // 0..1 spring value
velocity: number;
wallDist: number; // px, distance from this cell's centre to the shared wall
angle: number; // rad, direction from this cell to the neighbour
}
interface Cell {
col: number;
row: number;
x: number;
y: number;
spawnAt: number;
walls: Wall[]; // length 6, index-aligned with NEI_EVEN/NEI_ODD
recapStart: number | null;
}
function easeOutCubic(t: number): number {
const p = 1 - t;
return 1 - p * p * p;
}
export function HoneycombDraw({ paused = false, className = "", style }: HoneycombDrawProps) {
const wrapRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const uid = useId();
const pausedRef = useRef(paused);
pausedRef.current = paused;
useEffect(() => {
const wrap = wrapRef.current;
const canvas = canvasRef.current;
if (!wrap || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let cssW = 0;
let cssH = 0;
let dpr = 1;
let disposed = false;
let running = false;
let raf = 0;
let staticMode = false;
let lastMs = performance.now();
let simTime = 0;
let s = 40; // lattice spacing, recomputed on resize
let cols = 0;
let rows = 0;
let cells: (Cell | null)[] = [];
let filledCount = 0;
let slotTotal = 0;
let spawnAcc = 0;
let nextRecapAt = 0;
let border: [number, number, number] = [0.5, 0.5, 0.5];
let muted: [number, number, number] = [0.5, 0.5, 0.5];
let fg: [number, number, number] = [0.09, 0.09, 0.09];
const readColors = () => {
const cs = getComputedStyle(document.documentElement);
border = parseHex(cs.getPropertyValue("--border")) ?? [0.5, 0.5, 0.5];
muted = parseHex(cs.getPropertyValue("--ns-muted")) ?? [0.55, 0.55, 0.55];
fg = parseHex(cs.getPropertyValue("--foreground")) ?? [0.09, 0.09, 0.09];
};
readColors();
const cellAt = (c: number, r: number): Cell | null => {
if (c < 0 || r < 0 || c >= cols || r >= rows) return null;
return cells[r * cols + c];
};
const layout = () => {
const ref = Math.min(cssW, cssH) || 1;
s = ref / 11;
const rowH = s * (Math.sqrt(3) / 2);
cols = Math.max(1, Math.ceil(cssW / s) + 2);
rows = Math.max(1, Math.ceil(cssH / rowH) + 2);
cells = new Array(cols * rows).fill(null);
filledCount = 0;
slotTotal = cols * rows;
spawnAcc = 0;
nextRecapAt = simTime + RECAP_MIN + Math.random() * (RECAP_MAX - RECAP_MIN);
};
// Jittered lattice position for a slot — jitter is fixed per slot (seeded
// from its indices) so it never shifts after the cell is spawned.
const slotPos = (c: number, r: number): { x: number; y: number } => {
const rowH = s * (Math.sqrt(3) / 2);
const jseed = Math.sin(c * 12.9898 + r * 78.233) * 43758.5453;
const jx = ((jseed - Math.floor(jseed)) - 0.5) * 2 * (0.12 * s);
const jseed2 = Math.sin(c * 39.346 + r * 11.135) * 24634.634;
const jy = ((jseed2 - Math.floor(jseed2)) - 0.5) * 2 * (0.12 * s);
const x = c * s + (Math.abs(r % 2) === 1 ? s / 2 : 0) + jx;
const y = r * rowH + jy;
return { x, y };
};
const spawnCell = (c: number, r: number) => {
if (cellAt(c, r)) return;
const { x, y } = slotPos(c, r);
const cell: Cell = { col: c, row: r, x, y, spawnAt: simTime, walls: [], recapStart: null };
cells[r * cols + c] = cell;
filledCount++;
// walls are wired lazily once both cells in a pair exist, in linkWalls()
};
// Wire (or re-wire) the 6 potential walls for every cell whose neighbour
// set might have changed — cheap enough to run whenever a new cell spawns
// since it only touches existing neighbours.
const linkWalls = () => {
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const cell = cellAt(c, r);
if (!cell) continue;
const offsets = Math.abs(r % 2) === 1 ? NEI_ODD : NEI_EVEN;
if (cell.walls.length !== 6) {
cell.walls = offsets.map(() => ({
neighbor: null,
triggered: false,
progress: 0,
velocity: 0,
wallDist: s * 0.5,
angle: 0,
}));
}
for (let k = 0; k < 6; k++) {
const [dc, dr] = offsets[k];
const nb = cellAt(c + dc, r + dr);
const w = cell.walls[k];
if (nb && w.neighbor !== nb) {
w.neighbor = nb;
const dx = nb.x - cell.x;
const dy = nb.y - cell.y;
w.angle = Math.atan2(dy, dx);
w.wallDist = Math.hypot(dx, dy) * 0.5; // Voronoi bisector foot
}
}
}
}
};
const radiusOf = (cell: Cell, t: number): number => {
const age = t - cell.spawnAt;
const k = Math.max(0, Math.min(1, age / GROWTH_SECONDS));
return RADIUS_FRAC * s * easeOutCubic(k);
};
const springStep = (w: Wall, dt: number) => {
if (!w.triggered) return;
const target = 1;
const force = -SPRING_K * (w.progress - target) - 2 * Math.sqrt(SPRING_K) * SPRING_DAMP * w.velocity;
w.velocity += force * dt;
w.progress += w.velocity * dt;
if (w.progress > 1 && Math.abs(w.velocity) < 0.001) {
w.progress = 1;
w.velocity = 0;
}
};
const step = (dt: number) => {
simTime += dt;
// growth + overlap trigger + spring integration
for (let i = 0; i < cells.length; i++) {
const cell = cells[i];
if (!cell) continue;
const r0 = radiusOf(cell, simTime);
for (const w of cell.walls) {
if (!w.neighbor) continue;
if (!w.triggered) {
const r1 = radiusOf(w.neighbor, simTime);
const dist = w.wallDist * 2;
const overlap = r0 + r1 - dist;
if (overlap > OVERLAP_TRIGGER_FRAC * s) w.triggered = true;
}
springStep(w, dt);
}
}
// seed new cells along the left edge while under the fill cap
if (filledCount / slotTotal < FILL_CAP) {
spawnAcc += dt * SEED_RATE;
while (spawnAcc >= 1 && filledCount / slotTotal < FILL_CAP) {
spawnAcc -= 1;
// leftmost column with an empty slot, topmost row first
let spawned = false;
for (let c = 0; c < cols && !spawned; c++) {
for (let r = 0; r < rows; r++) {
if (!cellAt(c, r)) {
spawnCell(c, r);
spawned = true;
break;
}
}
}
if (!spawned) break;
}
linkWalls();
}
// recap flash: once the grid is essentially full, periodically re-flash
// one existing cell's wall stroke — this is what keeps the piece alive
// at rest indefinitely once growth finishes.
if (filledCount / slotTotal >= FILL_CAP * 0.999 && simTime >= nextRecapAt) {
const alive = cells.filter((c): c is Cell => !!c);
if (alive.length > 0) {
const pick = alive[Math.floor(Math.random() * alive.length)];
pick.recapStart = simTime;
}
nextRecapAt = simTime + RECAP_MIN + Math.random() * (RECAP_MAX - RECAP_MIN);
}
};
// Precomputes the fully-grown, fully-relaxed lattice deterministically —
// used both for the reduced-motion/paused SETTLED_COMB frame and to seed
// a resize so the surface never shows a blank field while catching up.
const settle = () => {
layout();
for (let c = 0; c < cols; c++) {
for (let r = 0; r < rows; r++) spawnCell(c, r);
}
linkWalls();
for (const cell of cells) {
if (!cell) continue;
cell.spawnAt = simTime - GROWTH_SECONDS * 2;
for (const w of cell.walls) {
if (!w.neighbor) continue;
w.triggered = true;
w.progress = 1;
w.velocity = 0;
}
}
};
const strokeColorFor = (cell: Cell, t: number): [number, number, number] => {
if (cell.recapStart == null) return border;
const elapsed = t - cell.recapStart;
if (elapsed < 0) return border;
if (elapsed < RECAP_FLASH) return fg;
if (elapsed < RECAP_FLASH + RECAP_FADE) {
return lerpRGB(fg, border, (elapsed - RECAP_FLASH) / RECAP_FADE);
}
cell.recapStart = null;
return border;
};
const drawCell = (cell: Cell, t: number) => {
const r0 = radiusOf(cell, t);
if (r0 <= 0.5) return;
// 6 control distances, one per lattice direction, blended between the
// circular radius and that specific wall's target distance by ITS OWN
// spring progress — the source of the wall-by-wall (not all-at-once)
// relaxation read.
const dirs: number[] = [];
const dists: number[] = [];
for (const w of cell.walls) {
const target = w.neighbor ? w.wallDist : r0;
const d = w.neighbor ? r0 + (target - r0) * w.progress : r0;
dirs.push(w.angle);
dists.push(Math.min(d, r0 * 1.05));
}
if (dirs.length === 0) {
ctx.beginPath();
ctx.arc(cell.x, cell.y, r0, 0, Math.PI * 2);
} else {
// sort control points by angle so the angular interpolation below
// walks the boundary in order
const order = dirs.map((_, i) => i).sort((a, b) => dirs[a] - dirs[b]);
const sortedAngles = order.map((i) => dirs[i]);
const sortedDists = order.map((i) => dists[i]);
const n = sortedAngles.length;
ctx.beginPath();
for (let i = 0; i < SAMPLES; i++) {
const theta = (i / SAMPLES) * Math.PI * 2 - Math.PI;
// find bracketing control points (wrap-around)
let k = 0;
while (k < n && sortedAngles[k] < theta) k++;
const k1 = ((k % n) + n) % n;
const k0 = ((k1 - 1) % n + n) % n;
let a0 = sortedAngles[k0];
let a1 = sortedAngles[k1];
if (a1 <= a0) a1 += Math.PI * 2;
let th = theta;
if (th < a0) th += Math.PI * 2;
const f = a1 > a0 ? (th - a0) / (a1 - a0) : 0;
const rad = sortedDists[k0] + (sortedDists[k1] - sortedDists[k0]) * f;
const px = cell.x + Math.cos(theta) * rad;
const py = cell.y + Math.sin(theta) * rad;
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
}
ctx.fillStyle = rgbaCss(muted, 0.1);
ctx.fill();
const [cr, cg, cb] = strokeColorFor(cell, t);
ctx.strokeStyle = rgbaCss([cr, cg, cb], cell.recapStart != null ? 0.9 : 0.55);
ctx.lineWidth = 1;
ctx.stroke();
};
const draw = () => {
if (cssW <= 0 || cssH <= 0) return;
ctx.clearRect(0, 0, cssW, cssH);
for (const cell of cells) {
if (cell) drawCell(cell, simTime);
}
};
const loop = (nowMs: number) => {
const rawMs = nowMs - lastMs;
const dt = Math.min(0.05, Math.max(0, rawMs / 1000));
lastMs = nowMs;
step(dt);
draw();
raf = requestAnimationFrame(loop);
};
const wake = () => {
if (running || disposed) return;
running = true;
lastMs = performance.now();
raf = requestAnimationFrame(loop);
};
const sleep = () => {
cancelAnimationFrame(raf);
running = false;
};
const applyBacking = () => {
if (cssW < 2 || cssH < 2) return;
dpr = Math.min(window.devicePixelRatio || 1, 2);
const pw = Math.round(cssW * dpr);
const ph = Math.round(cssH * dpr);
if (canvas.width !== pw || canvas.height !== ph) {
canvas.width = pw;
canvas.height = ph;
}
canvas.style.width = `${cssW}px`;
canvas.style.height = `${cssH}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
const resize = () => {
const rect = wrap.getBoundingClientRect();
if (rect.width < 2 || rect.height < 2) return;
cssW = rect.width;
cssH = rect.height;
applyBacking();
if (staticMode) {
settle();
} else {
layout();
}
draw();
};
const ro = new ResizeObserver(resize);
ro.observe(wrap);
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
let reduced = mq.matches;
const applyMode = () => {
if (reduced || pausedRef.current) {
staticMode = true;
sleep();
settle();
draw();
} else {
staticMode = false;
layout();
wake();
}
};
const onMq = () => {
reduced = mq.matches;
applyMode();
};
mq.addEventListener("change", onMq);
let onScreen = true;
const io = new IntersectionObserver(
(entries) => {
onScreen = entries.some((en) => en.isIntersecting);
if (!onScreen) sleep();
else if (!staticMode && !document.hidden) wake();
},
{ threshold: 0 }
);
io.observe(wrap);
const onVis = () => {
if (document.hidden) sleep();
else if (!staticMode && onScreen) wake();
};
document.addEventListener("visibilitychange", onVis);
const themeObserver = new MutationObserver(() => {
readColors();
if (staticMode) draw();
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
let lastPolledPaused = pausedRef.current;
let poll = 0;
const tick = () => {
if (pausedRef.current !== lastPolledPaused) {
lastPolledPaused = pausedRef.current;
applyMode();
}
poll = window.setTimeout(tick, 140);
};
tick();
resize();
applyMode();
return () => {
disposed = true;
ro.disconnect();
io.disconnect();
mq.removeEventListener("change", onMq);
document.removeEventListener("visibilitychange", onVis);
themeObserver.disconnect();
window.clearTimeout(poll);
sleep();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div
ref={wrapRef}
data-honeycomb-draw={uid}
className={`relative h-full w-full overflow-hidden bg-background ${className}`}
style={style}
>
<canvas ref={canvasRef} aria-hidden="true" className="absolute inset-0 block h-full w-full" />
</div>
);
}
HoneycombDraw.displayName = "HoneycombDraw";
Build spec
Build <HoneycombDraw paused? className? style?> as a card-scale 2D-canvas background built on real comb formation (Pirk et al. 2004; Bauer & Bienefeld 2013): packed circular wax cells relax into a hexagonal lattice through purely LOCAL wall-by-wall straightening, never a hex grid fading in. LATTICE: cell centres sit on a jittered 'odd-r' offset-triangular lattice, spacing s = min(containerW, containerH) / 11, each slot jittered up to +-0.12*s in both axes (jitter derived once per slot index via a cheap sine hash, fixed for that slot's lifetime, not re-rolled per frame). GROWTH: each cell's radius eases from 0 to 0.62*s over GROWTH_SECONDS=2.2s via ease-out-cubic, timed from that cell's own spawn moment. WALLS: every cell tracks up to 6 walls, one per lattice neighbour direction (odd-r offset neighbour table), each with its own independent state: untriggered until the pair's summed radii overlap the shared distance by more than OVERLAP_TRIGGER_FRAC=0.06*s, at which point (permanently — walls never un-trigger) the wall's spring target becomes 1 and a critically-damped spring (k=SPRING_K=90, damping ratio 1.0, semi-implicit Euler: velocity += (-k*(progress-1) - 2*sqrt(k)*damping*velocity)*dt; progress += velocity*dt) carries its progress from 0 to 1 over roughly 1.4s. wallDist for a wall is the Voronoi/perpendicular-bisector foot: half the straight-line distance to that neighbour's actual (jittered) centre. RENDER: a cell's boundary is sampled at SAMPLES=36 angles; at each sample angle the boundary radius is linearly interpolated between the two angularly-nearest walls' OWN blended distance (each wall's blended distance = lerp(currentCircleRadius, wallDist, thatWall'sSpringProgress) — directions with no neighbour, or whose wall hasn't triggered, stay at the plain circle radius) — this per-wall independence, not a single global relax factor, is what makes a partially-relaxed cell show straight facets next to round ones simultaneously, the visible signature that distinguishes this from a hex grid whose opacity is ramping (the spec's explicit kill criterion). SEEDING: while filled-slot-fraction < FILL_CAP=0.85, new cells spawn at SEED_RATE=0.6/s, chosen as the leftmost column's topmost still-empty slot (reading order), so growth visibly advances as a front from the left edge rather than popping in randomly. RECAP: once filled-fraction reaches ~FILL_CAP, a uniformly-random already-placed cell is picked every RECAP_MIN..RECAP_MAX=4..7s (re-rolled each time) and its stroke flashes to --foreground for RECAP_FLASH=0.26s then eases (linear) back to --border over RECAP_FADE=0.9s — this recap cycle is what keeps the piece alive at rest indefinitely once growth is done, since growth itself is a one-shot ~14s process for an 11-cell-wide card. TOKENS: wall strokes are --border at rest (0.55 alpha, a genuine hairline separator use, not a fill) and --foreground during a recap flash (never --ns-accent, no interactive control exists on this component); the low-alpha fill (0.1 alpha --ns-muted) is constant whether a cell is still round or fully hexagonal, read live via getComputedStyle(document.documentElement) at mount and re-read on a MutationObserver watching documentElement's class. STATIC/REDUCED-MOTION: prefers-reduced-motion and paused both call settle(), which lays out every lattice slot, spawns every cell with its spawnAt backdated past GROWTH_SECONDS, and force-sets every wall to triggered/progress=1/velocity=0 in one synchronous pass (SETTLED_COMB) — the freeze frame is the fully-filled, fully-relaxed hexagon lattice with zero mid-relaxation cells and no recap flash firing, never a blank or half-built grid. `ResizeObserver` re-derives spacing and re-lays the grid (settle() if currently static, otherwise a fresh empty-then-growing layout()); `IntersectionObserver` (threshold 0) and `visibilitychange` both pause the single rAF loop. Canvas is `aria-hidden` decoration with `w-full h-full`; this is a pure background layer with no accessible content of its own — callers place real content in a sibling layer above it, as in demo.tsx.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| paused? | boolean | false | Freezes on the fully-relaxed SETTLED_COMB frame without unmounting. |
| className? | string | — | — |
| style? | React.CSSProperties | — | — |