# Dot Grid

Dot grid whose dots swell and warm up near the pointer.

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

## Install

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

## API — Dot Grid
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `gap` | `number` | `22` |  |
| `radius` | `number` | `140` | Pointer influence in px. |

## Example — Dot Grid

```tsx
import { DotGrid } from "@/components/ui/dot-grid";

export default function DotGridDemo() {
  return (
    <div className="relative grid h-80 w-full place-items-center overflow-hidden rounded-xl border bg-background">
      <DotGrid />
      <p className="relative text-sm text-muted-foreground">Move your pointer over the grid.</p>
    </div>
  );
}
```

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

```tsx
"use client";

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

export type DotGridProps = {
  /** Pixels between dots. */
  gap?: number;
  /** Pointer influence radius in px. */
  radius?: number;
  className?: string;
};

function rgbOf(el: HTMLElement, varName: string, fallback: string) {
  const c = document.createElement("canvas").getContext("2d")!;
  c.fillStyle = getComputedStyle(el).getPropertyValue(varName).trim() || fallback;
  c.fillRect(0, 0, 1, 1);
  return Array.from(c.getImageData(0, 0, 1, 1).data.slice(0, 3));
}

export function DotGrid({ gap = 22, radius = 140, className }: DotGridProps) {
  const ref = React.useRef<HTMLCanvasElement>(null);
  const reduce = useReducedMotion();

  React.useEffect(() => {
    const canvas = ref.current;
    if (!canvas) return;
    const host = canvas.parentElement!.parentElement!;
    const ctx = canvas.getContext("2d")!;
    let w = 0;
    let h = 0;
    let heat: Float32Array = new Float32Array(0);
    let cols = 0;
    let rows = 0;
    let pointer = { x: -9999, y: -9999 };
    let raf = 0;
    let running = false;
    let base = rgbOf(canvas, "--muted-foreground", "#878787");
    let hot = rgbOf(canvas, "--ember", "#0285f7");

    const resize = () => {
      const dpr = Math.min(2, window.devicePixelRatio || 1);
      w = canvas.clientWidth;
      h = canvas.clientHeight;
      canvas.width = w * dpr;
      canvas.height = h * dpr;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      cols = Math.ceil(w / gap) + 1;
      rows = Math.ceil(h / gap) + 1;
      heat = new Float32Array(cols * rows);
    };

    const frame = () => {
      let active = false;
      ctx.clearRect(0, 0, w, h);
      const ox = (w - (cols - 1) * gap) / 2;
      const oy = (h - (rows - 1) * gap) / 2;
      for (let r = 0; r < rows; r++) {
        for (let c = 0; c < cols; c++) {
          const i = r * cols + c;
          const x = ox + c * gap;
          const y = oy + r * gap;
          const d = Math.hypot(x - pointer.x, y - pointer.y);
          const target = d < radius ? Math.pow(1 - d / radius, 2) : 0;
          // Ease each dot toward its target so the glow trails the pointer.
          heat[i] += (target - heat[i]) * (reduce ? 1 : 0.16);
          if (Math.abs(target - heat[i]) > 0.002) active = true;
          const t = heat[i];
          const col = base.map((b, k) => Math.round(b + (hot[k] - b) * t));
          ctx.fillStyle = `rgba(${col[0]},${col[1]},${col[2]},${0.28 + 0.72 * t})`;
          ctx.beginPath();
          ctx.arc(x, y, 1 + t * 1.8, 0, Math.PI * 2);
          ctx.fill();
        }
      }
      if (active) raf = requestAnimationFrame(frame);
      else running = false;
    };

    const kick = () => {
      if (running) return;
      running = true;
      raf = requestAnimationFrame(frame);
    };

    const onMove = (e: PointerEvent) => {
      const rect = canvas.getBoundingClientRect();
      pointer = { x: e.clientX - rect.left, y: e.clientY - rect.top };
      kick();
    };
    const onLeave = () => {
      pointer = { x: -9999, y: -9999 };
      kick();
    };

    resize();
    kick();
    const ro = new ResizeObserver(() => {
      resize();
      kick();
    });
    ro.observe(canvas);
    const mo = new MutationObserver(() => {
      base = rgbOf(canvas, "--muted-foreground", "#878787");
      hot = rgbOf(canvas, "--ember", "#0285f7");
      kick();
    });
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme"] });
    host.addEventListener("pointermove", onMove);
    host.addEventListener("pointerleave", onLeave);
    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      mo.disconnect();
      host.removeEventListener("pointermove", onMove);
      host.removeEventListener("pointerleave", onLeave);
    };
  }, [gap, radius, reduce]);

  // The canvas ignores the pointer; the nearest positioned parent listens instead, so content above stays clickable.
  return (
    <div aria-hidden className={cn("pointer-events-none absolute inset-0", className)}>
      <canvas ref={ref} className="size-full" />
    </div>
  );
}
```