Hex Grid
A seamlessly tiling honeycomb backdrop — outlined, tinted or vertex-dotted, with an optional edge fade and a few seeded cells breathing on CSS alone.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/hex-grid.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "HexGrid" component — a honeycomb
backdrop that tiles seamlessly at any size. Its only dependency is a cn()
class merger (clsx + tailwind-merge). It IS a client component ("use client")
for exactly one reason: React.useId().
Contract
- export function HexGrid(props): props extend
Omit<React.ComponentProps<"div">, "children"> (rest props spread onto the
root div) plus:
- size?: number (default 28, clamped 6-160) — hexagon side length in px. The
density knob.
- strokeWidth?: number (default 1, clamped 0.25-4) — line width in px; also
the basis for the dot radius (r = strokeWidth * 1.15).
- variant?: "outline" | "filled" | "dots" (default "outline").
- fade?: boolean (default true) — radial mask dissolving toward the edges.
- tone?: "border" | "muted" | "primary" (default "border").
- glow?: boolean (default false) — a few cells breathe.
- seed?: number (default 1) — integer that picks WHICH cells glow.
- It renders no children: it is a decoration layer, like a background image.
The consumer puts it inside a `relative isolate` ancestor and writes content
in a `relative` sibling that stacks above it.
- Every numeric prop is clamped and non-finite values fall back to the default.
size=0 would divide the tile by zero when deriving the row pitch.
Behavior — the tile (this is the part that is easy to get wrong)
- A single hexagon CANNOT tile a rectangle. Repeating one leaves gaps between
the staggered rows, which is the classic bug in hand-rolled hex backgrounds.
The correct rectangular repeat unit of a pointy-top comb is
sqrt(3)*s wide by 3*s tall and holds TWO staggered rows: row r sits at
y = s + r*(1.5*s) and is offset by half a hexagon width on odd r.
- Round both tile dimensions to whole pixels and derive the hexagon from them
(side = tileH/3, half width = tileW/2). The ~0.5px of distortion is
invisible; the payoff is that the tile edge lands on a device-pixel boundary,
where <pattern> clipping cannot leave an antialiased hairline seam.
- Emit cells one ring beyond the tile (rows -1..2, columns -1..cols). The
<pattern> clips the overhang and the neighbouring tile draws the matching
half, so a shape crossing the seam is completed rather than cut. Emitting a
superset of the true lattice is safe; emitting less is not.
- Draw only THREE of the six edges per cell — upper-left, upper-right, right,
as one polyline `M(x-halfW,y-side/2) L(x,y-side) L(x+halfW,y-side/2)
L(x+halfW,y+side/2)`. The other three edges belong to the neighbours, so
every shared edge is stroked exactly once. Drawing whole hexagons instead
double-strokes every interior edge, and two overlapping antialiased strokes
composite darker than one — you get a comb with uneven line weight.
- strokeLinecap/strokeLinejoin="round": the open ends of the polyline land on
lattice vertices where a neighbour's round join already paints the same
disc, so the joins stay clean without any extra geometry.
- variant="filled": tint a proper 3-COLOURING of the lattice — colour =
(i - floor(r/2) + 2r) mod 3, filled when it is 0. No two neighbouring cells
share a colour, so the tinted cells scatter instead of striping. That
colouring only repeats every third column, so the filled tile is 3 columns
wide (the height is unchanged). The outline is still drawn on top of it.
- variant="dots": drop the edges and mark the lattice vertices. Each cell
contributes its top and bottom vertex only — the other four vertices are the
top/bottom of a neighbour — so every vertex is emitted exactly once. Filter
out the dots that cannot touch the tile (a 5px margin covers the largest
clamped radius); that alone takes the default tile from 24 circles to 8.
- glow: candidate cells live on a 3-column x 6-row super-tile (18 cells, i.e.
3x3 of the honeycomb tile — a bigger repeat keeps the lit cells from reading
as a grid of their own). Rank all 18 by an integer hash of (i, r, seed) and
take the LOWEST 3. Ranking, not thresholding: a probability threshold makes
the count swing with the seed (measured 2-8 of 18 across seeds), which is the
difference between "a few cells" and "a rash". Spread the 3 across 3 phases
by rank, so every phase is always used. Look the selection up modulo the
super-tile when emitting the surrounding ring, so a lit cell that crosses the
super-tile edge is completed by the neighbouring tile.
- Determinism is mandatory: the hash is a bit-mixing integer hash
(Math.imul + xorshift), never Math.random() and never Date.now(). Random
values during render break SSR/client parity and are a lint error under
react-hooks/purity. The whole tile is memoized on (size, variant, glow, seed).
- Animate the <rect> that USES the glow pattern, never the nodes inside
<defs>. CSS animations on pattern content are not reliably repainted across
engines; animating the referencing element always is. One @keyframes
(opacity .12 -> 1 -> .12) plus a per-phase animation-delay of
phase * duration/3. No requestAnimationFrame anywhere: an idle background
must not own a frame budget.
- The @keyframes ship inside the component via a React 19 hoisted
<style href="zyeon-hex-grid" precedence="medium"> tag — no Tailwind config
edits, and multiple instances dedupe to one style tag by href. It is only
rendered when glow is on.
- prefers-reduced-motion: motion-reduce:[animation:none] sits in the same
arbitrary-value class as the animation, so it out-ranks it. The lit cells
then hold at their static opacity=0.5 — the pattern and its highlights stay
fully on screen, only the breathing stops.
- UNIQUE PATTERN IDS ARE MANDATORY. SVG ids are document-global, so two
instances sharing one make the second silently paint the first one's tile.
Derive them from React.useId() and strip the characters that are illegal in
an XML id (and therefore inside url(#...)):
`zy-hex-${React.useId().replace(/[^a-zA-Z0-9]/g, "")}`, then suffix per
pattern. This is the only reason the component needs "use client".
Rendering & styling
- Semantic tokens only. Every shape is fill/stroke="currentColor" and the root
div sets that color with a token utility plus the alpha that makes both
themes read the same: "border" -> text-border, "muted" ->
text-muted-foreground opacity-35, "primary" -> text-primary opacity-25.
No hex / rgb() / oklch() anywhere, so the comb re-skins itself with the host
theme and gets dark mode for free. Both halves are plain utilities, so a
consumer's className can out-merge either one through cn().
- The filled cells use fillOpacity 0.45 of that same currentColor, so the tint
always sits below the line weight instead of competing with it. Caveat worth
knowing: in a shadcn dark theme --border is itself only ~10% white, so
variant="filled" with tone="border" lands near 4% and reads as nothing.
Pair filled with "muted" or "primary"; "border" is for the outline modes.
- fade is a CSS mask-image (radial-gradient(ellipse at center, black 45%,
transparent 85%)) on the root — an alpha stencil, so its black/transparent
keywords are not rendered colors and cost nothing in token terms.
- Root: aria-hidden="true" pointer-events-none absolute inset-0
overflow-hidden. It is pure decoration so it must never take a click.
- Payload, honestly: at the defaults the whole component is ~0.9 kB of SSR HTML
of which the path data is ~300 bytes, because the tile always holds the same
fixed handful of cells no matter how big they are. filled is ~1.4 kB, dots
~1.0 kB (8 circles), glow adds two more patterns for ~2.5 kB total. `size` is
therefore NOT a payload knob — it is a raster knob: the tile repeats
(area / tileArea) times, so halving `size` quadruples the number of repeats
the compositor has to paint.
Customization levers
- Density: `size`, the hexagon side in px. 14-20 reads as fine mesh, 26-40 as a
comfortable backdrop, 60+ as a few large cells behind a hero.
- Pattern mode: `variant`. outline for structure, filled for a mosaic with
weight, dots for the quietest possible texture.
- Weight: `strokeWidth` in px, exact because the pattern is authored in user
space at 1:1 (no compensating transform needed).
- Palette: `tone` maps to a (token, alpha) pair — extend that record with e.g.
accent or destructive instead of hardcoding a color anywhere.
- Edges: `fade` on/off. Off makes the comb meet the container border, which
suits a bordered card; on suits a full-bleed section. Swap the mask for a
linear-gradient through className to fade in one direction only.
- Motion: `glow` on/off, `seed` to pick different cells, and the
--zy-hex-breath custom property for the period (default 7s). Below ~3s the
pulsing stops being background.
- Glow character: the constants for cells-per-super-tile (3), phases (3) and
super-tile size (3x6). More lit cells at a lower opacity reads as shimmer;
fewer at a higher opacity reads as beacons.
- Fill mix: `filled` tints one of three colour classes. Tint two classes by
testing `!== 0` for a denser mosaic, or offset the class by the seed to
reshuffle which third lights up.Concepts
- Two-row rectangular repeat — a hexagon is not a rectangle, so the repeat unit of a honeycomb is a
sqrt(3)·s × 3·sbox holding two staggered rows. Tiling a single hexagon is the classic hex-background bug: the rows can never interlock and the pattern shows a grid of gaps. - Overhang, then clip — cells are emitted one ring outside the tile.
<pattern>clips the overhang, and the neighbouring tile paints the matching half, so a shape crossing the seam is completed rather than truncated. Emitting a superset of the true lattice is always safe; emitting less leaves holes. - Shared edges drawn once — each cell contributes only its upper-left, upper-right and right edge; the rest belong to its neighbours. Drawing whole hexagons double-strokes every interior edge, and two overlapping antialiased strokes composite darker than one, which shows up as uneven line weight across the comb.
- Three-colouring mosaic —
(i - floor(r/2) + 2r) mod 3is a proper colouring of the hex lattice: no cell shares a colour with any of its six neighbours. Tinting one class scatters the fill evenly instead of striping it, and it is why thefilledtile is three columns wide. - Rank, don't threshold — the glowing cells are the lowest three hashes among the eighteen candidates, not "every cell whose hash is under 0.17". A threshold makes the lit count swing with the seed (measured 2–8 of 18), so the seed would silently change how loud the effect is.
- Animate the user, not the def — CSS animations on nodes inside
<defs>are not reliably repainted across engines, so the breathing lives on the<rect>that references the pattern. Underprefers-reduced-motionthat animation is dropped and the lit cells hold at a static opacity — the pattern never disappears with the motion.