# Hexagon Grid

A honeycomb of faint hexagons that light up under the pointer and cool slowly behind it, leaving a glowing trail, with a few cells sparking on their own. Canvas, written in the file.

**Category:** Backgrounds  
**Exports:** `HexagonGrid`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/hexagon-grid.json
```
The file lands at `components/ui/hexagon-grid.tsx`; import from `@/components/ui/hexagon-grid`.

## API — Hexagon Grid
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `size` | `number` | `26` | Px. |
| `color` | `string` | `"var(--ember)"` |  |
| `fade` | `number` | `1.2` | Seconds. |
| `ambient` | `number` | `2` | Cells per second. |
| `className` | `string` | — |  |

## Example — Hexagon Grid

```tsx
import { HexagonGrid } from "@/components/ui/hexagon-grid";

export default function HexagonGridDemo({ size = 26, fade = 1.2, ambient = 2, color = "var(--ember)" }: { size?: number; fade?: number; ambient?: number; color?: string }) {
  return (
    <div className="relative grid h-80 w-full place-items-center overflow-hidden rounded-xl border bg-background">
      <HexagonGrid size={size} fade={fade} ambient={ambient} color={color} />
      <p className="relative text-center text-2xl font-semibold tracking-tight">Trace a path</p>
    </div>
  );
}
```

## Source — components/ui/hexagon-grid.tsx

```tsx
"use client";

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

export type HexagonGridProps = {
  /** Hexagon radius in px. */
  size?: number;
  /** Glow colour; any CSS colour. */
  color?: string;
  /** Seconds a lit cell takes to cool. */
  fade?: number;
  /** Random cells that light on their own, per second. */
  ambient?: number;
  className?: string;
};

const SQ3 = Math.sqrt(3);

/** A honeycomb of faint hexagons that light up under the pointer and cool slowly behind it, leaving a glowing trail. Canvas, in the file. */
export function HexagonGrid({ size = 26, color = "var(--ember)", fade = 1.2, ambient = 2, className }: HexagonGridProps) {
  const ref = React.useRef<HTMLCanvasElement>(null);
  const reduce = useReducedMotion();
  const live = React.useRef({ color, fade, ambient });
  live.current = { color, fade, ambient };

  React.useEffect(() => {
    const canvas = ref.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d")!;
    const grid = document.createElement("canvas");
    const gctx = grid.getContext("2d")!;
    let w = 0, h = 0, dpr = 1, raf = 0, last = 0, visible = true, spawn = 0;
    const heat = new Map<string, { q: number; r: number; v: number }>();
    const resolve = (c: string) => {
      const m = c.trim().match(/^var\((--[\w-]+)\)$/);
      return m ? getComputedStyle(canvas).getPropertyValue(m[1]).trim() || "#0285f7" : c;
    };
    const center = (q: number, r: number) => [size * SQ3 * (q + r / 2), size * 1.5 * r] as const;
    const path = (c: CanvasRenderingContext2D, x: number, y: number, s: number) => {
      c.beginPath();
      for (let i = 0; i < 6; i++) {
        const a = (Math.PI / 180) * (60 * i - 30);
        c.lineTo(x + s * Math.cos(a), y + s * Math.sin(a));
      }
      c.closePath();
    };
    // The idle grid never changes, so it's drawn once per resize onto an offscreen canvas.
    const paintGrid = () => {
      grid.width = canvas.width;
      grid.height = canvas.height;
      gctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      gctx.strokeStyle = resolve("var(--border)") || "rgba(128,128,128,.25)";
      gctx.lineWidth = 1;
      const rows = Math.ceil(h / (size * 1.5)) + 1;
      for (let r = -1; r <= rows; r++) {
        const qs = -Math.ceil(r / 2) - 1, qe = Math.ceil(w / (size * SQ3)) - Math.floor(r / 2) + 1;
        for (let q = qs; q <= qe; q++) {
          const [x, y] = center(q, r);
          path(gctx, x, y, size - 1.5);
          gctx.stroke();
        }
      }
    };
    const resize = () => {
      dpr = Math.min(2, devicePixelRatio || 1);
      w = canvas.clientWidth;
      h = canvas.clientHeight;
      canvas.width = Math.max(1, Math.floor(w * dpr));
      canvas.height = Math.max(1, Math.floor(h * dpr));
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      paintGrid();
    };
    const light = (q: number, r: number, v = 1) => {
      const k = `${q},${r}`;
      const cell = heat.get(k);
      if (cell) cell.v = Math.max(cell.v, v);
      else heat.set(k, { q, r, v });
    };
    const at = (px: number, py: number) => {
      // Pixel to axial, then cube-round to the nearest hexagon.
      const fq = ((SQ3 / 3) * px - py / 3) / size, fr = ((2 / 3) * py) / size, fs = -fq - fr;
      let q = Math.round(fq), r = Math.round(fr);
      const s = Math.round(fs);
      const dq = Math.abs(q - fq), dr = Math.abs(r - fr), ds = Math.abs(s - fs);
      if (dq > dr && dq > ds) q = -r - s;
      else if (dr > ds) r = -q - s;
      return [q, r] as const;
    };
    const draw = (dt: number) => {
      ctx.clearRect(0, 0, w, h);
      ctx.save();
      ctx.setTransform(1, 0, 0, 1, 0, 0);
      ctx.drawImage(grid, 0, 0);
      ctx.restore();
      const col = resolve(live.current.color);
      const k = dt / Math.max(0.05, live.current.fade);
      for (const [key, c] of heat) {
        c.v -= k;
        if (c.v <= 0) {
          heat.delete(key);
          continue;
        }
        const [x, y] = center(c.q, c.r);
        path(ctx, x, y, size - 1.5);
        ctx.globalAlpha = c.v * 0.35;
        ctx.fillStyle = col;
        ctx.fill();
        ctx.globalAlpha = Math.min(1, c.v * 1.2);
        ctx.strokeStyle = col;
        ctx.lineWidth = 1.5;
        ctx.stroke();
      }
      ctx.globalAlpha = 1;
    };
    const loop = (now: number) => {
      const dt = last ? Math.min(0.05, (now - last) / 1000) : 0.016;
      last = now;
      spawn += dt * live.current.ambient;
      while (spawn >= 1) {
        spawn -= 1;
        const [q, r] = at(Math.random() * w, Math.random() * h);
        light(q, r, 0.7);
      }
      draw(dt);
      if (visible) raf = requestAnimationFrame(loop);
    };
    const move = (e: PointerEvent) => {
      const b = canvas.getBoundingClientRect();
      const x = e.clientX - b.left, y = e.clientY - b.top;
      if (x < 0 || y < 0 || x > b.width || y > b.height) return;
      const [q, r] = at(x, y);
      light(q, r);
    };
    resize();
    if (reduce) {
      draw(0);
      return;
    }
    window.addEventListener("pointermove", move, { passive: true });
    raf = requestAnimationFrame(loop);
    const ro = new ResizeObserver(resize);
    ro.observe(canvas);
    const io = new IntersectionObserver(([e]) => {
      visible = e.isIntersecting;
      if (visible) {
        cancelAnimationFrame(raf);
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    });
    io.observe(canvas);
    const mo = new MutationObserver(paintGrid);
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
    return () => {
      cancelAnimationFrame(raf);
      window.removeEventListener("pointermove", move);
      ro.disconnect();
      io.disconnect();
      mo.disconnect();
    };
  }, [reduce, size]);

  return <canvas ref={ref} aria-hidden className={cn("pointer-events-none absolute inset-0 size-full", className)} />;
}
```