{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"glyph-hover","type":"registry:ui","title":"Glyph Hover","description":"Letters near the cursor dissolve into flickering glyphs and settle back into the original text, strongest right under the pointer.","dependencies":[],"registryDependencies":[],"files":[{"path":"registry/ui/glyph-hover.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { Fragment, useEffect, useRef, type CSSProperties } from \"react\";\n\nconst css = `\n.scr-c{position:relative;display:inline-block;white-space:pre}\n.scr-c[data-g]{color:transparent}\n.scr-c[data-g]::after{content:attr(data-g);position:absolute;inset:0;text-align:center;color:var(--scr-color)}\n`;\n\nexport interface GlyphHoverProps {\n  text?: string;\n  /** Pointer radius that scrambles characters, in px. */\n  radius?: number;\n  /** How long a character right under the pointer keeps scrambling, in ms. */\n  duration?: number;\n  /** Ms between glyph swaps. */\n  speed?: number;\n  /** Glyphs used while scrambling. */\n  scrambleChars?: string;\n  /** Color of scrambled glyphs. */\n  scrambleColor?: string;\n  className?: string;\n}\n\nexport function GlyphHover({\n  text = \"Move your cursor across this paragraph. Every letter it passes dissolves into noise for a moment, then snaps right back into place as if nothing happened.\",\n  radius = 100,\n  duration = 1200,\n  speed = 50,\n  scrambleChars = \"!<>-_\\\\/[]{}=+*^?#01\",\n  scrambleColor = \"#a3e635\",\n  className,\n}: GlyphHoverProps) {\n  const root = useRef<HTMLParagraphElement>(null);\n\n  useEffect(() => {\n    const el = root.current!;\n    if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return;\n    const chars = Array.from(el.querySelectorAll<HTMLSpanElement>(\".scr-c\"));\n    const until = new Float64Array(chars.length);\n    const active = new Set<number>();\n    let centers: { x: number; y: number }[] = [];\n    let raf = 0;\n    let lastSwap = 0;\n\n    const measure = () => {\n      const r = el.getBoundingClientRect();\n      centers = chars.map((c) => {\n        const b = c.getBoundingClientRect();\n        return { x: b.left - r.left + b.width / 2, y: b.top - r.top + b.height / 2 };\n      });\n    };\n    const glyph = () => scrambleChars[Math.floor(Math.random() * scrambleChars.length)] ?? \"\";\n\n    const loop = (now: number) => {\n      const swap = now - lastSwap >= speed;\n      if (swap) lastSwap = now;\n      for (const i of active) {\n        if (now >= until[i]) {\n          delete chars[i].dataset.g;\n          active.delete(i);\n        } else if (swap) chars[i].dataset.g = glyph();\n      }\n      raf = active.size ? requestAnimationFrame(loop) : 0;\n    };\n\n    const move = (e: PointerEvent) => {\n      if (!centers.length) measure();\n      const r = el.getBoundingClientRect();\n      const x = e.clientX - r.left;\n      const y = e.clientY - r.top;\n      const now = performance.now();\n      centers.forEach((c, i) => {\n        const d = Math.hypot(c.x - x, c.y - y);\n        if (d >= radius || chars[i].textContent === \" \") return;\n        until[i] = Math.max(until[i], now + duration * (1 - d / radius));\n        if (!active.has(i)) {\n          active.add(i);\n          chars[i].dataset.g = glyph();\n        }\n      });\n      if (active.size && !raf) raf = requestAnimationFrame(loop);\n    };\n    const reset = () => (centers = []);\n\n    el.addEventListener(\"pointerenter\", reset);\n    el.addEventListener(\"pointermove\", move, { passive: true });\n    window.addEventListener(\"resize\", reset);\n    return () => {\n      cancelAnimationFrame(raf);\n      el.removeEventListener(\"pointerenter\", reset);\n      el.removeEventListener(\"pointermove\", move);\n      window.removeEventListener(\"resize\", reset);\n      chars.forEach((c) => delete c.dataset.g);\n    };\n  }, [text, radius, duration, speed, scrambleChars]);\n\n  const words = text.split(\" \");\n  return (\n    <p ref={root} className={className} style={{ \"--scr-color\": scrambleColor } as CSSProperties}>\n      <style href=\"glyph-hover\" precedence=\"default\">\n        {css}\n      </style>\n      <span className=\"sr-only\">{text}</span>\n      <span aria-hidden>\n        {words.map((word, w) => (\n          <Fragment key={w}>\n            <span style={{ display: \"inline-block\", whiteSpace: \"nowrap\" }}>\n              {Array.from(word).map((c, i) => (\n                <span key={i} className=\"scr-c\">\n                  {c}\n                </span>\n              ))}\n            </span>\n            {w < words.length - 1 && \" \"}\n          </Fragment>\n        ))}\n      </span>\n    </p>\n  );\n}\n"}]}