{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "interactive-grid",
  "title": "Interactive Grid",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A canvas grid whose cells light up under the pointer and fade out behind it, leaving a glowing trail, drawn only while something is moving and listening on its parent so content above never blocks it.",
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/color.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/interactive-grid.tsx",
      "content": "// Ballmac UI: Interactive Grid. https://ui.ballmac.com/components/interactive-grid\n\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { observeTheme, resolveCssColor } from \"@/lib/ballmac/color\"\n\ntype InteractiveGridProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"color\"> & {\n  /** Side of one cell in pixels. */\n  cell?: number\n  /** Color of the lit cells: a CSS variable name such as \"--chart-1\", or any CSS color. */\n  color?: string\n  /** Color of the grid lines. */\n  lineColor?: string\n  /** How many cells around the pointer light up. */\n  radius?: number\n  /** Seconds a lit cell takes to fade away. */\n  decay?: number\n  /** Fade the grid toward the edges. */\n  fade?: boolean\n}\n\n/**\n * A canvas grid whose cells light up under the pointer and fade out behind it, leaving a trail.\n * It listens on its positioned parent, so content placed above it does not block the effect. Decorative.\n */\nfunction InteractiveGrid({ cell = 36, color = \"--chart-1\", lineColor = \"--border\", radius = 2.2, decay = 1.4, fade = true, className, style, ...props }: InteractiveGridProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const canvasRef = React.useRef<HTMLCanvasElement>(null)\n\n  React.useEffect(() => {\n    const root = rootRef.current\n    const canvas = canvasRef.current\n    const ctx = canvas?.getContext(\"2d\")\n    const host = root?.parentElement\n    if (!root || !canvas || !ctx || !host) return\n    const reduceMotion = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n\n    let width = 0\n    let height = 0\n    let cols = 0\n    let rows = 0\n    let dpr = 1\n    let heat = new Float32Array(0)\n    let lit = resolveCssColor(root, color)\n    let lines = resolveCssColor(root, lineColor)\n    let frame = 0\n    let last = 0\n    let active = false\n\n    const resize = () => {\n      dpr = Math.min(window.devicePixelRatio || 1, 2)\n      width = root.clientWidth\n      height = root.clientHeight\n      canvas.width = Math.round(width * dpr)\n      canvas.height = Math.round(height * dpr)\n      cols = Math.ceil(width / cell)\n      rows = Math.ceil(height / cell)\n      heat = new Float32Array(cols * rows)\n    }\n\n    const draw = () => {\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n      ctx.clearRect(0, 0, width, height)\n      for (let c = 0; c < cols; c++) {\n        for (let r = 0; r < rows; r++) {\n          const h = heat[c * rows + r]!\n          if (h > 0.01) {\n            ctx.globalAlpha = h * 0.85\n            ctx.fillStyle = lit\n            ctx.fillRect(c * cell + 1, r * cell + 1, cell - 1, cell - 1)\n          }\n        }\n      }\n      ctx.globalAlpha = 1\n      ctx.strokeStyle = lines\n      ctx.lineWidth = 1\n      ctx.beginPath()\n      for (let c = 0; c <= cols; c++) {\n        ctx.moveTo(c * cell + 0.5, 0)\n        ctx.lineTo(c * cell + 0.5, height)\n      }\n      for (let r = 0; r <= rows; r++) {\n        ctx.moveTo(0, r * cell + 0.5)\n        ctx.lineTo(width, r * cell + 0.5)\n      }\n      ctx.stroke()\n    }\n\n    const loop = (now: number) => {\n      const dt = last ? Math.min((now - last) / 1000, 0.1) : 0\n      last = now\n      let any = false\n      for (let i = 0; i < heat.length; i++) {\n        if (heat[i]! > 0) {\n          heat[i] = Math.max(0, heat[i]! - dt / decay)\n          any = any || heat[i]! > 0\n        }\n      }\n      draw()\n      if (any) frame = requestAnimationFrame(loop)\n      else active = false\n    }\n    const wake = () => {\n      if (active || reduceMotion) return\n      active = true\n      last = 0\n      frame = requestAnimationFrame(loop)\n    }\n\n    const onMove = (e: PointerEvent) => {\n      if (reduceMotion) return\n      const box = root.getBoundingClientRect()\n      const px = (e.clientX - box.left) / cell\n      const py = (e.clientY - box.top) / cell\n      if (px < -radius || py < -radius || px > cols + radius || py > rows + radius) return\n      for (let c = Math.max(0, Math.floor(px - radius)); c <= Math.min(cols - 1, Math.ceil(px + radius)); c++) {\n        for (let r = Math.max(0, Math.floor(py - radius)); r <= Math.min(rows - 1, Math.ceil(py + radius)); r++) {\n          const d = Math.hypot(c + 0.5 - px, r + 0.5 - py)\n          if (d < radius) heat[c * rows + r] = Math.max(heat[c * rows + r]!, 1 - d / radius)\n        }\n      }\n      wake()\n    }\n\n    resize()\n    draw()\n    host.addEventListener(\"pointermove\", onMove)\n    const ro = new ResizeObserver(() => {\n      resize()\n      draw()\n    })\n    ro.observe(root)\n    const stopTheme = observeTheme(() => {\n      lit = resolveCssColor(root, color)\n      lines = resolveCssColor(root, lineColor)\n      draw()\n    })\n    return () => {\n      cancelAnimationFrame(frame)\n      host.removeEventListener(\"pointermove\", onMove)\n      ro.disconnect()\n      stopTheme()\n    }\n  }, [cell, color, lineColor, radius, decay])\n\n  const mask = fade ? \"radial-gradient(ellipse 75% 75% at 50% 50%, black 35%, transparent 90%)\" : undefined\n  return (\n    <div\n      ref={rootRef}\n      data-slot=\"interactive-grid\"\n      aria-hidden=\"true\"\n      className={cn(\"pointer-events-none absolute inset-0 overflow-hidden\", className)}\n      style={{ maskImage: mask, WebkitMaskImage: mask, ...style }}\n      {...props}\n    >\n      <canvas ref={canvasRef} className=\"size-full\" />\n    </div>\n  )\n}\n\nexport { InteractiveGrid, type InteractiveGridProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/interactive-grid.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "grid",
      "canvas",
      "hover",
      "trail",
      "background"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "<InteractiveGrid cell color lineColor radius decay fade /> inside a relative parent. Colors are CSS variable names. It listens to the parent, repaints on theme changes and stops its frame loop when idle.",
      "whenToUse": [
        "Heroes and empty states that should respond to the mouse",
        "Playful 404 pages"
      ],
      "whenNotToUse": [
        "Touch-only audiences (there is no hover)",
        "Large amounts of text over the grid"
      ],
      "composesWith": [
        "grid-pattern",
        "flickering-grid",
        "particles"
      ],
      "a11y": [
        {
          "keys": "Screen readers",
          "action": "Decorative: aria-hidden and pointer-events-none"
        },
        {
          "keys": "Reduced motion",
          "action": "A still grid; nothing lights up"
        }
      ],
      "customization": [
        "cell",
        "color and lineColor",
        "radius",
        "decay",
        "fade"
      ]
    },
    "examples": [
      "interactive-grid-demo",
      "interactive-grid-colors"
    ],
    "url": "https://ui.ballmac.com/components/interactive-grid"
  },
  "categories": [
    "backgrounds"
  ],
  "type": "registry:ui"
}