{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ascii-image",
  "title": "ASCII Image",
  "description": "Renders any image as ASCII art on a canvas, with a glittering pixel trail under the cursor.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/new-york/ascii-image/ascii-image.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { useReducedMotion } from \"motion/react\"\nimport { cn } from \"@/lib/utils\"\n\nexport interface AsciiImageProps {\n  /** Image URL. Remote images must allow CORS so their pixels can be read. */\n  src: string\n  /** Accessible description of the image. */\n  alt: string\n  /** Size of each character cell in px. Default: 10 */\n  cellSize?: number\n  /** Characters from lightest to densest. Default: \" .:-=+*#%@\" */\n  characters?: string\n  /** Character color (any canvas color, not CSS variables). Default: \"#3b5bff\" */\n  color?: string\n  /** Color of the glitter sparks near the cursor. Default: \"#ffffff\" */\n  sparkColor?: string\n  /** Radius of the hover region in px. Default: 90 */\n  radius?: number\n  /** How quickly the hover trail fades, 0 to 1. Higher = longer trail. Default: 0.93 */\n  decay?: number\n  /** Chance per frame that a lit cell sparkles, 0 to 1. Default: 0.12 */\n  glitter?: number\n  /** Brightness contrast applied before mapping to characters. Default: 1.3 */\n  contrast?: number\n  /** Map dark pixels to dense characters instead of light ones. Default: false */\n  invert?: boolean\n  className?: string\n}\n\nexport function AsciiImage({\n  src,\n  alt,\n  cellSize = 10,\n  characters = \" .:-=+*#%@\",\n  color = \"#3b5bff\",\n  sparkColor = \"#ffffff\",\n  radius = 90,\n  decay = 0.93,\n  glitter = 0.12,\n  contrast = 1.3,\n  invert = false,\n  className,\n}: AsciiImageProps) {\n  const wrapRef = useRef<HTMLDivElement>(null)\n  const canvasRef = useRef<HTMLCanvasElement>(null)\n  const reduced = useReducedMotion()\n  const [error, setError] = useState(false)\n\n  useEffect(() => {\n    const wrap = wrapRef.current\n    const canvas = canvasRef.current\n    const ctx = canvas?.getContext(\"2d\")\n    if (!wrap || !canvas || !ctx) return\n\n    let cols = 0\n    let rows = 0\n    let luma = new Float32Array(0) // 0..1 brightness per cell\n    let energy = new Float32Array(0) // 0..1 hover intensity per cell\n    let raf = 0\n    let running = false\n    let pointer: { x: number; y: number } | null = null\n    let disposed = false\n\n    const img = new Image()\n    img.crossOrigin = \"anonymous\"\n\n    const sample = () => {\n      const { width, height } = wrap.getBoundingClientRect()\n      if (!width || !height || !img.naturalWidth) return\n      const dpr = Math.min(window.devicePixelRatio || 1, 2)\n      canvas.width = Math.round(width * dpr)\n      canvas.height = Math.round(height * dpr)\n      canvas.style.width = `${width}px`\n      canvas.style.height = `${height}px`\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n\n      cols = Math.ceil(width / cellSize)\n      rows = Math.ceil(height / cellSize)\n\n      // Draw the image \"object-fit: cover\" into a tiny canvas, one pixel per cell\n      const small = document.createElement(\"canvas\")\n      small.width = cols\n      small.height = rows\n      const sctx = small.getContext(\"2d\", { willReadFrequently: true })!\n      const scale = Math.max(cols / img.naturalWidth, rows / img.naturalHeight)\n      const w = img.naturalWidth * scale\n      const h = img.naturalHeight * scale\n      sctx.drawImage(img, (cols - w) / 2, (rows - h) / 2, w, h)\n\n      let data: Uint8ClampedArray\n      try {\n        data = sctx.getImageData(0, 0, cols, rows).data\n      } catch {\n        setError(true) // tainted canvas: the image host doesn't allow CORS\n        return\n      }\n\n      luma = new Float32Array(cols * rows)\n      energy = new Float32Array(cols * rows)\n      for (let i = 0; i < cols * rows; i++) {\n        const r = data[i * 4]\n        const g = data[i * 4 + 1]\n        const b = data[i * 4 + 2]\n        const a = data[i * 4 + 3] / 255\n        let l = ((0.2126 * r + 0.7152 * g + 0.0722 * b) / 255) * a\n        l = Math.min(Math.max((l - 0.5) * contrast + 0.5, 0), 1)\n        luma[i] = invert ? 1 - l : l\n      }\n      draw()\n    }\n\n    const draw = () => {\n      const { width, height } = canvas.getBoundingClientRect()\n      ctx.clearRect(0, 0, width, height)\n      ctx.font = `${cellSize}px ui-monospace, SFMono-Regular, Menlo, monospace`\n      ctx.textAlign = \"center\"\n      ctx.textBaseline = \"middle\"\n      const ramp = characters.length - 1\n      const block = cellSize * 0.82\n      let active = false\n\n      for (let y = 0; y < rows; y++) {\n        for (let x = 0; x < cols; x++) {\n          const i = y * cols + x\n          const l = luma[i]\n          const e = energy[i]\n          const cx = x * cellSize + cellSize / 2\n          const cy = y * cellSize + cellSize / 2\n\n          if (e > 0.02) {\n            active = true\n            if (l < 0.08) continue\n            // Lit region: solid blocks with random sparkles\n            const spark = !reduced && Math.random() < glitter * e\n            ctx.globalAlpha = Math.min(1, 0.35 + e)\n            ctx.fillStyle = spark ? sparkColor : color\n            ctx.fillRect(cx - block / 2, cy - block / 2, block, block)\n            continue\n          }\n\n          const ci = Math.round(l * ramp)\n          if (ci === 0) continue\n          ctx.globalAlpha = 0.25 + l * 0.75\n          ctx.fillStyle = color\n          ctx.fillText(characters[ci], cx, cy)\n        }\n      }\n      ctx.globalAlpha = 1\n      return active\n    }\n\n    const tick = () => {\n      if (pointer) {\n        const r = radius / cellSize\n        const px = pointer.x / cellSize\n        const py = pointer.y / cellSize\n        const x0 = Math.max(0, Math.floor(px - r))\n        const x1 = Math.min(cols - 1, Math.ceil(px + r))\n        const y0 = Math.max(0, Math.floor(py - r))\n        const y1 = Math.min(rows - 1, Math.ceil(py + r))\n        for (let y = y0; y <= y1; y++) {\n          for (let x = x0; x <= x1; x++) {\n            // Jittered falloff gives the region a ragged, pixelated edge\n            const d = Math.hypot(x - px, y - py) / r + Math.random() * 0.35\n            if (d < 1) energy[y * cols + x] = Math.max(energy[y * cols + x], 1 - d)\n          }\n        }\n      }\n      for (let i = 0; i < energy.length; i++) energy[i] *= decay\n\n      const active = draw()\n      if (active || pointer) raf = requestAnimationFrame(tick)\n      else running = false\n    }\n\n    const start = () => {\n      if (running || disposed) return\n      running = true\n      raf = requestAnimationFrame(tick)\n    }\n\n    const onMove = (e: PointerEvent) => {\n      const rect = canvas.getBoundingClientRect()\n      pointer = { x: e.clientX - rect.left, y: e.clientY - rect.top }\n      start()\n    }\n    const onLeave = () => {\n      pointer = null\n    }\n\n    img.onload = sample\n    img.onerror = () => setError(true)\n    img.src = src\n\n    const ro = new ResizeObserver(() => sample())\n    ro.observe(wrap)\n    wrap.addEventListener(\"pointermove\", onMove)\n    wrap.addEventListener(\"pointerleave\", onLeave)\n\n    return () => {\n      disposed = true\n      cancelAnimationFrame(raf)\n      ro.disconnect()\n      wrap.removeEventListener(\"pointermove\", onMove)\n      wrap.removeEventListener(\"pointerleave\", onLeave)\n    }\n  }, [src, cellSize, characters, color, sparkColor, radius, decay, glitter, contrast, invert, reduced])\n\n  return (\n    <div ref={wrapRef} role=\"img\" aria-label={alt} className={cn(\"relative overflow-hidden\", className)}>\n      <canvas ref={canvasRef} className=\"absolute inset-0\" />\n      {error && (\n        <p className=\"absolute inset-0 grid place-items-center p-4 text-center text-sm text-muted-foreground\">\n          Couldn&apos;t read this image. Make sure it is same-origin or served with CORS.\n        </p>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "Docs, live playground and every prop: https://trytweenly.vercel.app/docs/components/ascii-image",
  "type": "registry:component"
}