{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"ember-cells","type":"registry:ui","title":"Ember Cells","description":"A grid of cells that light up under the cursor and cool into a fading color trail.","dependencies":[],"registryDependencies":[],"files":[{"path":"registry/ui/ember-cells.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\nexport interface EmberCellsProps {\n  /** Cell size in px. */\n  cellSize?: number;\n  /** Gap between cells in px. */\n  gap?: number;\n  /** Corner radius in px. */\n  radius?: number;\n  /** Idle cell outline color. */\n  baseColor?: string;\n  /** Color of freshly lit cells (6-digit hex). */\n  color?: string;\n  /** Color lit cells cool down to as they fade (6-digit hex). */\n  trailColor?: string;\n  /** Ms for a lit cell to fade out. */\n  fade?: number;\n  /** Brush radius in cells. */\n  brush?: number;\n  className?: string;\n}\n\nconst rgb = (hex: string) => {\n  const n = parseInt(hex.replace(\"#\", \"\").padEnd(6, \"0\").slice(0, 6), 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n};\n\nexport function EmberCells({\n  cellSize = 36,\n  gap = 4,\n  radius = 6,\n  baseColor = \"#27272a\",\n  color = \"#c6ff3d\",\n  trailColor = \"#22d3ee\",\n  fade = 1400,\n  brush = 1.5,\n  className,\n}: EmberCellsProps) {\n  const ref = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\n    const canvas = ref.current!;\n    const host = canvas.parentElement!;\n    const ctx = canvas.getContext(\"2d\")!;\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const hot = rgb(color);\n    const cool = rgb(trailColor);\n    const step = cellSize + gap;\n    let cols = 0;\n    let rows = 0;\n    let w = 0;\n    let h = 0;\n    let heat = new Float32Array(0);\n    let raf = 0;\n    let last = 0;\n    let prev: { x: number; y: number } | null = null;\n\n    const draw = (now: number) => {\n      const dt = last ? Math.min(now - last, 64) : 16;\n      last = now;\n      ctx.clearRect(0, 0, w, h);\n      const ox = (w - cols * step + gap) / 2;\n      const oy = (h - rows * step + gap) / 2;\n      ctx.strokeStyle = baseColor;\n      ctx.lineWidth = 1;\n      ctx.beginPath();\n      for (let i = 0; i < heat.length; i++) {\n        ctx.roundRect(ox + (i % cols) * step + 0.5, oy + Math.floor(i / cols) * step + 0.5, cellSize - 1, cellSize - 1, radius);\n      }\n      ctx.stroke();\n\n      let alive = false;\n      const decay = reduced ? 1 : dt / fade;\n      for (let i = 0; i < heat.length; i++) {\n        const v = heat[i];\n        if (v <= 0) continue;\n        alive = true;\n        // Fresh cells glow hot, then cool toward the trail color as they fade.\n        const c = hot.map((a, j) => Math.round(cool[j] + (a - cool[j]) * v * v));\n        const x = ox + (i % cols) * step;\n        const y = oy + Math.floor(i / cols) * step;\n        const s = cellSize * (0.82 + 0.18 * v);\n        ctx.globalAlpha = Math.min(1, v * 1.2);\n        ctx.fillStyle = ctx.shadowColor = `rgb(${c[0]},${c[1]},${c[2]})`;\n        ctx.shadowBlur = 18 * v;\n        ctx.beginPath();\n        ctx.roundRect(x + (cellSize - s) / 2, y + (cellSize - s) / 2, s, s, radius);\n        ctx.fill();\n        heat[i] = Math.max(0, v - decay);\n      }\n      ctx.globalAlpha = 1;\n      ctx.shadowBlur = 0;\n      raf = alive ? requestAnimationFrame(draw) : 0;\n      if (!alive) last = 0;\n    };\n    const schedule = () => {\n      if (!raf) raf = requestAnimationFrame(draw);\n    };\n\n    const light = (px: number, py: number) => {\n      const ox = (w - cols * step + gap) / 2;\n      const oy = (h - rows * step + gap) / 2;\n      const gx = (px - ox) / step - 0.5;\n      const gy = (py - oy) / step - 0.5;\n      const r = Math.ceil(brush);\n      for (let y = Math.round(gy) - r; y <= Math.round(gy) + r; y++) {\n        for (let x = Math.round(gx) - r; x <= Math.round(gx) + r; x++) {\n          if (x < 0 || y < 0 || x >= cols || y >= rows) continue;\n          const d = Math.hypot(x - gx, y - gy);\n          const v = 1 - d / (brush + 0.5);\n          const i = y * cols + x;\n          if (v > heat[i]) heat[i] = Math.min(1, v * 1.4);\n        }\n      }\n    };\n\n    const onMove = (e: PointerEvent) => {\n      const r = canvas.getBoundingClientRect();\n      const p = { x: e.clientX - r.left, y: e.clientY - r.top };\n      // Fill the gap between pointer events so fast strokes leave a continuous trail.\n      const from = prev ?? p;\n      const n = Math.max(1, Math.ceil(Math.hypot(p.x - from.x, p.y - from.y) / (step / 2)));\n      for (let k = 1; k <= n; k++) light(from.x + ((p.x - from.x) * k) / n, from.y + ((p.y - from.y) * k) / n);\n      prev = p;\n      schedule();\n    };\n    const onLeave = () => {\n      prev = null;\n    };\n\n    const ro = new ResizeObserver(() => {\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      w = canvas.offsetWidth;\n      h = canvas.offsetHeight;\n      canvas.width = w * dpr;\n      canvas.height = h * dpr;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      cols = Math.max(1, Math.floor((w + gap) / step));\n      rows = Math.max(1, Math.floor((h + gap) / step));\n      heat = new Float32Array(cols * rows);\n      schedule();\n    });\n    ro.observe(canvas);\n    host.addEventListener(\"pointermove\", onMove, { passive: true });\n    host.addEventListener(\"pointerleave\", onLeave);\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      host.removeEventListener(\"pointermove\", onMove);\n      host.removeEventListener(\"pointerleave\", onLeave);\n    };\n  }, [cellSize, gap, radius, baseColor, color, trailColor, fade, brush]);\n\n  return (\n    <canvas\n      ref={ref}\n      aria-hidden\n      className={className}\n      style={{ position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\", pointerEvents: \"none\" }}\n    />\n  );\n}\n"}]}