{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"ember-path","type":"registry:ui","title":"Ember Path","description":"Grid pixels ignite along the cursor path, burn white-hot and shrink away as they fade.","dependencies":[],"registryDependencies":[],"files":[{"path":"registry/ui/ember-path.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\nexport interface EmberPathProps {\n  /** Cell size in px. */\n  pixelSize?: number;\n  /** Ms for a lit pixel to fade out. */\n  decay?: number;\n  /** Brush radius in cells. */\n  brush?: number;\n  color?: string;\n  /** Faint grid lines under the trail. */\n  showGrid?: boolean;\n  /** Draw a wandering trail while the cursor is away. */\n  idle?: boolean;\n  className?: string;\n}\n\nfunction hexToRgb(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 EmberPath({\n  pixelSize = 24,\n  decay = 700,\n  brush = 1,\n  color = \"#c6ff3d\",\n  showGrid = true,\n  idle = true,\n  className,\n}: EmberPathProps) {\n  const ref = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\n    const canvas = ref.current!;\n    const host = canvas.parentElement ?? canvas;\n    const ctx = canvas.getContext(\"2d\")!;\n    const [r, g, b] = hexToRgb(color);\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    let w = 0;\n    let h = 0;\n    let cols = 0;\n    let rows = 0;\n    let cells = new Float32Array(0);\n    let raf = 0;\n    let visible = true;\n    let last: { x: number; y: number } | null = null;\n    let lastMove = -1e9;\n    let wandering = false;\n    let prev = performance.now();\n\n    const resize = () => {\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.ceil(w / pixelSize);\n      rows = Math.ceil(h / pixelSize);\n      cells = new Float32Array(cols * rows);\n    };\n\n    const stamp = (x: number, y: number) => {\n      const cx = Math.floor(x / pixelSize);\n      const cy = Math.floor(y / pixelSize);\n      for (let dy = -brush; dy <= brush; dy++) {\n        for (let dx = -brush; dx <= brush; dx++) {\n          if (dx * dx + dy * dy > brush * brush + 0.5) continue;\n          const i = cx + dx;\n          const j = cy + dy;\n          if (i >= 0 && j >= 0 && i < cols && j < rows) cells[j * cols + i] = 1;\n        }\n      }\n    };\n\n    // Stamp every cell between the previous and current point so fast moves leave no gaps.\n    const paint = (x: number, y: number) => {\n      if (last) {\n        const steps = Math.ceil(Math.hypot(x - last.x, y - last.y) / (pixelSize / 2));\n        for (let s = 1; s <= steps; s++) stamp(last.x + ((x - last.x) * s) / steps, last.y + ((y - last.y) * s) / steps);\n      } else stamp(x, y);\n      last = { x, y };\n    };\n\n    const frame = (now: number) => {\n      const dt = Math.min(now - prev, 50);\n      prev = now;\n      if (idle && now - lastMove > 1500) {\n        const t = now / 1000;\n        if (!wandering) last = null;\n        wandering = true;\n        paint(w / 2 + Math.sin(t * 0.9) * w * 0.32, h / 2 + Math.sin(t * 1.7) * h * 0.3);\n      }\n      ctx.clearRect(0, 0, w, h);\n      if (showGrid) {\n        ctx.fillStyle = `rgba(${r},${g},${b},0.05)`;\n        for (let i = 1; i < cols; i++) ctx.fillRect(i * pixelSize, 0, 1, h);\n        for (let j = 1; j < rows; j++) ctx.fillRect(0, j * pixelSize, w, 1);\n      }\n      const fade = dt / decay;\n      const gap = Math.max(1, pixelSize * 0.08);\n      for (let j = 0; j < rows; j++) {\n        for (let i = 0; i < cols; i++) {\n          const k = j * cols + i;\n          const v = cells[k];\n          if (v <= 0) continue;\n          cells[k] = Math.max(0, v - fade);\n          // Fresh pixels burn white-hot, then cool to the color and shrink away.\n          const hot = Math.max(0, v - 0.75) * 4;\n          ctx.fillStyle = `rgba(${r + (255 - r) * hot},${g + (255 - g) * hot},${b + (255 - b) * hot},${v})`;\n          const s = (pixelSize - gap) * (0.35 + 0.65 * v);\n          const o = (pixelSize - s) / 2;\n          ctx.fillRect(i * pixelSize + o, j * pixelSize + o, s, s);\n        }\n      }\n    };\n\n    const loop = (now: number) => {\n      frame(now);\n      raf = visible ? requestAnimationFrame(loop) : 0;\n    };\n\n    const onMove = (e: PointerEvent) => {\n      const rect = canvas.getBoundingClientRect();\n      if (wandering) last = null;\n      wandering = false;\n      lastMove = performance.now();\n      paint(e.clientX - rect.left, e.clientY - rect.top);\n    };\n    const onLeave = () => {\n      last = null;\n    };\n\n    const ro = new ResizeObserver(resize);\n    ro.observe(canvas);\n    resize();\n    if (reduced) return () => ro.disconnect();\n\n    const io = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting;\n      if (visible && !raf) {\n        prev = performance.now();\n        raf = requestAnimationFrame(loop);\n      }\n    });\n    io.observe(canvas);\n    host.addEventListener(\"pointermove\", onMove, { passive: true });\n    host.addEventListener(\"pointerleave\", onLeave);\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      io.disconnect();\n      host.removeEventListener(\"pointermove\", onMove);\n      host.removeEventListener(\"pointerleave\", onLeave);\n    };\n  }, [pixelSize, decay, brush, color, showGrid, idle]);\n\n  return (\n    <canvas\n      ref={ref}\n      aria-hidden\n      className={className}\n      style={{ position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\" }}\n    />\n  );\n}\n"}]}