{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pixel-trail",
  "type": "registry:ui",
  "title": "Pixel Trail",
  "description": "A canvas grid whose cells light up under the pointer and fade out, leaving a pixel trail.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/pixel-trail.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type PixelTrailShape = \"square\" | \"round\";\n\nexport interface PixelTrailProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Grid cell size, in px. */\n  cellSize?: number;\n  /** How long a lit cell takes to fade out, in ms. */\n  fadeDuration?: number;\n  /** Colour of lit cells (any CSS colour). */\n  color?: string;\n  /** Cell shape. */\n  shape?: PixelTrailShape;\n  /** Inset between neighbouring lit cells, in px. */\n  gap?: number;\n  /** Occasionally light random cells while idle. */\n  twinkle?: boolean;\n  /** Draw a faint hairline grid (squares) or dot grid (round). */\n  showGrid?: boolean;\n}\n\ntype RGB = [number, number, number];\n\n/* ---------------------------------- Colour --------------------------------- */\n\nlet probe: CanvasRenderingContext2D | null = null;\n\n/** Resolves any CSS colour (hex, rgb(), oklch()…) to RGB by painting it on a 1×1 canvas. */\nfunction resolveColor(input: string, fallback: RGB): RGB {\n  probe ??= document.createElement(\"canvas\").getContext(\"2d\", { willReadFrequently: true });\n  if (!probe) return fallback;\n  probe.clearRect(0, 0, 1, 1);\n  probe.fillStyle = \"rgba(0, 0, 0, 0)\";\n  probe.fillStyle = input; // invalid colours are ignored → transparent → fallback\n  probe.fillRect(0, 0, 1, 1);\n  const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;\n  return a === 0 ? fallback : [r, g, b];\n}\n\nconst luminance = ([r, g, b]: RGB) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;\n\n/** On light themes (dark ink), very light colours are pulled toward the ink so they stay visible. */\nfunction legible(color: RGB, ink: RGB): RGB {\n  if (luminance(ink) > 0.5 || luminance(color) < 0.62) return color;\n  return color.map((v, i) => Math.round(v + (ink[i] - v) * 0.42)) as RGB;\n}\n\n/** Re-reads colours when the theme changes: class/data-theme on <html> or a scoped [data-theme] ancestor. */\nfunction observeTheme(el: Element, onChange: () => void) {\n  const mo = new MutationObserver(onChange);\n  const options = { attributes: true, attributeFilter: [\"class\", \"data-theme\"] };\n  mo.observe(document.documentElement, options);\n  const scoped = el.parentElement?.closest(\"[data-theme]\");\n  if (scoped && scoped !== document.documentElement) mo.observe(scoped, options);\n  const mq = window.matchMedia(\"(prefers-color-scheme: dark)\");\n  mq.addEventListener(\"change\", onChange);\n  return () => {\n    mo.disconnect();\n    mq.removeEventListener(\"change\", onChange);\n  };\n}\n\n/* ----------------------------- Reduced motion ------------------------------ */\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/* -------------------------------- Component -------------------------------- */\n\nconst ALPHA_STEPS = 16;\nconst TWINKLE_PER_CELL_PER_SECOND = 0.004;\n\n/**\n * A grid of cells that light up under the pointer (mouse, pen or touch) and fade out, leaving a\n * pixel trail. Everything is drawn on one canvas: only lit cells are tracked and painted, the\n * hairline grid is cached offscreen, and the loop sleeps whenever nothing is lit.\n */\nexport function PixelTrail({\n  cellSize = 24,\n  fadeDuration = 900,\n  color = \"#FF7448\",\n  shape = \"square\",\n  gap = 2,\n  twinkle = true,\n  showGrid = true,\n  className = \"\",\n  children,\n  ...props\n}: PixelTrailProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    const ctx = canvas?.getContext(\"2d\");\n    if (!canvas || !ctx) return;\n\n    const cell = Math.max(4, cellSize);\n    const inset = Math.min(cell / 2 - 1, Math.max(0, gap)) / 2;\n    const fade = Math.max(50, fadeDuration);\n    const gridLayer = document.createElement(\"canvas\");\n\n    let width = 0;\n    let height = 0;\n    let dpr = 1;\n    let cols = 0;\n    let rows = 0;\n    let ox = 0;\n    let oy = 0;\n    let raf = 0;\n    let last = 0;\n    let inView = false;\n    let fills: string[] = [];\n\n    // Lit cells: brightness per cell plus a compact list of the indices currently above zero.\n    let level = new Float32Array(0);\n    let active: number[] = [];\n    // Last pointer position in canvas space; null after the pointer leaves or lifts.\n    let prev: { x: number; y: number } | null = null;\n\n    const readTheme = () => {\n      const ink = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);\n      const c = legible(resolveColor(color, [255, 116, 72]), ink);\n      fills = Array.from({ length: ALPHA_STEPS + 1 }, (_, i) => `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${(i / ALPHA_STEPS).toFixed(3)})`);\n      paintGrid(ink);\n    };\n\n    /** The idle grid is static, so it is rendered once into an offscreen canvas. */\n    const paintGrid = (ink: RGB) => {\n      gridLayer.width = canvas.width;\n      gridLayer.height = canvas.height;\n      const g = gridLayer.getContext(\"2d\");\n      if (!g || !showGrid) return;\n      g.setTransform(dpr, 0, 0, dpr, 0, 0);\n      if (shape === \"square\") {\n        g.strokeStyle = `rgba(${ink[0]}, ${ink[1]}, ${ink[2]}, 0.07)`;\n        g.lineWidth = 1 / dpr;\n        g.beginPath();\n        for (let c = 0; c <= cols; c++) {\n          const x = Math.round((ox + c * cell) * dpr) / dpr + 0.5 / dpr;\n          g.moveTo(x, 0);\n          g.lineTo(x, height);\n        }\n        for (let r = 0; r <= rows; r++) {\n          const y = Math.round((oy + r * cell) * dpr) / dpr + 0.5 / dpr;\n          g.moveTo(0, y);\n          g.lineTo(width, y);\n        }\n        g.stroke();\n      } else {\n        g.fillStyle = `rgba(${ink[0]}, ${ink[1]}, ${ink[2]}, 0.16)`;\n        g.beginPath();\n        for (let r = 0; r < rows; r++) {\n          for (let c = 0; c < cols; c++) {\n            const x = ox + (c + 0.5) * cell;\n            const y = oy + (r + 0.5) * cell;\n            g.moveTo(x + 1, y);\n            g.arc(x, y, 1, 0, Math.PI * 2);\n          }\n        }\n        g.fill();\n      }\n    };\n\n    const draw = () => {\n      ctx.setTransform(1, 0, 0, 1, 0, 0);\n      ctx.clearRect(0, 0, canvas.width, canvas.height);\n      if (showGrid) ctx.drawImage(gridLayer, 0, 0);\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      const size = cell - inset * 2;\n      for (const idx of active) {\n        const v = level[idx];\n        // Ease-out fade: bright for most of the lifetime, quick tail at the end.\n        const alpha = Math.round(Math.min(1, v * (2 - v)) * 0.92 * ALPHA_STEPS);\n        if (alpha <= 0) continue;\n        const x = ox + (idx % cols) * cell + inset;\n        const y = oy + Math.floor(idx / cols) * cell + inset;\n        ctx.fillStyle = fills[alpha];\n        if (shape === \"round\") {\n          ctx.beginPath();\n          ctx.arc(x + size / 2, y + size / 2, size / 2, 0, Math.PI * 2);\n          ctx.fill();\n        } else ctx.fillRect(x, y, size, size);\n      }\n    };\n\n    const light = (idx: number, value: number) => {\n      if (level[idx] <= 0) active.push(idx);\n      if (value > level[idx]) level[idx] = value;\n    };\n\n    const cellAt = (x: number, y: number) => {\n      const c = Math.floor((x - ox) / cell);\n      const r = Math.floor((y - oy) / cell);\n      return c < 0 || r < 0 || c >= cols || r >= rows ? -1 : r * cols + c;\n    };\n\n    /** Lights every cell along the segment from the previous position so fast moves leave no gaps. */\n    const trace = (x: number, y: number) => {\n      if (reduceMotion) {\n        // Calm alternative: only the cell under the pointer is lit, with no lingering trail.\n        for (const idx of active) level[idx] = 0;\n        active = [];\n        const idx = cellAt(x, y);\n        if (idx >= 0) light(idx, 0.75);\n        draw();\n        return;\n      }\n      const from = prev ?? { x, y };\n      const steps = Math.max(1, Math.ceil(Math.hypot(x - from.x, y - from.y) / (cell * 0.5)));\n      for (let s = 1; s <= steps; s++) {\n        const idx = cellAt(from.x + ((x - from.x) * s) / steps, from.y + ((y - from.y) * s) / steps);\n        if (idx >= 0) light(idx, 1);\n      }\n      prev = { x, y };\n      schedule();\n    };\n\n    const twinkling = () => twinkle && !reduceMotion;\n\n    const frame = (now: number) => {\n      raf = 0;\n      const dt = Math.min(64, last ? now - last : 16.7);\n      last = now;\n\n      if (twinkling()) {\n        // Poisson-ish sprinkling proportional to the number of cells.\n        let expected = cols * rows * TWINKLE_PER_CELL_PER_SECOND * (dt / 1000);\n        while (expected > 0) {\n          if (Math.random() < expected) light(Math.floor(Math.random() * cols * rows), 0.3 + Math.random() * 0.3);\n          expected -= 1;\n        }\n      }\n\n      const decay = dt / fade;\n      for (let i = active.length - 1; i >= 0; i--) {\n        const idx = active[i];\n        level[idx] -= decay;\n        if (level[idx] <= 0) {\n          level[idx] = 0;\n          // Swap-remove keeps the active list compact.\n          active[i] = active[active.length - 1];\n          active.pop();\n        }\n      }\n\n      draw();\n      if (active.length > 0 || twinkling()) schedule();\n      else last = 0;\n    };\n\n    const schedule = () => {\n      if (!raf && inView && !reduceMotion && !document.hidden) raf = requestAnimationFrame(frame);\n    };\n    const stop = () => {\n      cancelAnimationFrame(raf);\n      raf = 0;\n      last = 0;\n    };\n\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect();\n      width = rect.width;\n      height = rect.height;\n      dpr = Math.min(2, window.devicePixelRatio || 1);\n      canvas.width = Math.max(1, Math.round(width * dpr));\n      canvas.height = Math.max(1, Math.round(height * dpr));\n      cols = Math.max(1, Math.ceil(width / cell));\n      rows = Math.max(1, Math.ceil(height / cell));\n      // Centre the grid so partial cells are split evenly between opposite edges.\n      ox = (width - cols * cell) / 2;\n      oy = (height - rows * cell) / 2;\n      level = new Float32Array(cols * rows);\n      active = [];\n      prev = null;\n      readTheme();\n      draw();\n      schedule();\n    };\n\n    const onThemeChange = () => {\n      readTheme();\n      draw();\n    };\n\n    const toLocal = (clientX: number, clientY: number) => {\n      const rect = canvas.getBoundingClientRect();\n      const x = clientX - rect.left;\n      const y = clientY - rect.top;\n      return x >= 0 && y >= 0 && x <= rect.width && y <= rect.height ? { x, y } : null;\n    };\n\n    const handle = (clientX: number, clientY: number) => {\n      const p = toLocal(clientX, clientY);\n      if (p) trace(p.x, p.y);\n      else release();\n    };\n\n    const release = () => {\n      prev = null;\n      if (reduceMotion && active.length) {\n        for (const idx of active) level[idx] = 0;\n        active = [];\n        draw();\n      }\n    };\n\n    // Touch is handled by passive touch listeners (they keep firing while the page scrolls);\n    // mouse and pen use pointer events. Nothing calls preventDefault, so scrolling is never blocked.\n    const onPointerMove = (e: PointerEvent) => {\n      if (e.pointerType !== \"touch\") handle(e.clientX, e.clientY);\n    };\n    const onTouch = (e: TouchEvent) => {\n      const t = e.touches[0];\n      if (t) handle(t.clientX, t.clientY);\n    };\n\n    const onVisibility = () => (document.hidden ? stop() : schedule());\n\n    const listeners = (on: boolean) => {\n      if (on) {\n        window.addEventListener(\"pointermove\", onPointerMove, { passive: true });\n        window.addEventListener(\"touchstart\", onTouch, { passive: true });\n        window.addEventListener(\"touchmove\", onTouch, { passive: true });\n        window.addEventListener(\"touchend\", release, { passive: true });\n        window.addEventListener(\"touchcancel\", release, { passive: true });\n        document.documentElement.addEventListener(\"pointerleave\", release);\n      } else {\n        window.removeEventListener(\"pointermove\", onPointerMove);\n        window.removeEventListener(\"touchstart\", onTouch);\n        window.removeEventListener(\"touchmove\", onTouch);\n        window.removeEventListener(\"touchend\", release);\n        window.removeEventListener(\"touchcancel\", release);\n        document.documentElement.removeEventListener(\"pointerleave\", release);\n      }\n    };\n\n    const io = new IntersectionObserver(([entry]) => {\n      inView = entry.isIntersecting;\n      listeners(inView);\n      if (inView) schedule();\n      else {\n        release();\n        stop();\n      }\n    });\n\n    resize();\n    const ro = new ResizeObserver(resize);\n    ro.observe(canvas);\n    io.observe(canvas);\n    const unobserveTheme = observeTheme(canvas, onThemeChange);\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    return () => {\n      stop();\n      ro.disconnect();\n      io.disconnect();\n      unobserveTheme();\n      listeners(false);\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n    };\n  }, [cellSize, fadeDuration, color, shape, gap, twinkle, showGrid, reduceMotion]);\n\n  return (\n    <div className={`absolute inset-0 overflow-hidden ${className}`} {...props}>\n      <canvas\n        ref={canvasRef}\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 block h-full w-full text-zinc-900 dark:text-zinc-100\"\n      />\n      {children != null && <div className=\"relative h-full w-full\">{children}</div>}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "backgrounds"
  ],
  "docs": "Documentation: https://wancraft.my/components/pixel-trail"
}
