Backgrounds

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.

Preview in your theme

Loading preview…

"use client"

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

/**
 * Keyframes ship inside the component via React 19 hoisted <style> —
 * no tailwind config edits, and duplicates dedupe by href. Only the optional
 * glow layers use them; the honeycomb itself never animates, so the default
 * component costs zero frames.
 */
const KEYFRAMES = `@keyframes zy-hex-breath{0%,100%{opacity:.12}50%{opacity:1}}`

/** Alpha-only mask — the keyword colors here are stencil, not paint. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/hex-grid.json

Prompt

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·s box 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 3 is 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 the filled tile 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. Under prefers-reduced-motion that animation is dropped and the lit cells hold at a static opacity — the pattern never disappears with the motion.

On This Page