{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "glyph-trail",
  "type": "registry:ui",
  "title": "Glyph Trail",
  "author": "Rishabh Bose <https://theb0se.vercel.app/>",
  "description": "Interactive monospace text matrix that scrambles characters into kinetic mathematical glyphs under cursor proximity, relaxing back with fluid color decay.",
  "dependencies": [
    "framer-motion",
    "clsx",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "components/ui/glyph-trail.tsx",
      "content": "\"use client\";\n\nimport React, {\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useReducer,\n  useRef,\n} from \"react\";\nimport { motion, useReducedMotion } from \"framer-motion\";\nimport { cn } from \"@/lib/utils\";\n\nconst useIsomorphicLayoutEffect =\n  typeof window !== \"undefined\" ? useLayoutEffect : useEffect;\n\n/* ------------------------------------------------------------------ */\n/* Config & Constants                                                 */\n/* ------------------------------------------------------------------ */\n\nexport const DEFAULT_GLYPH_LINES = [\n  \"stem docs\",\n  \"ideas logic\",\n  \"natural input\",\n  \"text corca math\",\n  \"formulas notation\",\n  \"functions variables\",\n  \"collaborative editing\",\n];\n\n// Symbols a character can turn into while the cursor passes over it.\nexport const DEFAULT_GLYPHS = Array.from(\n  \"∈∃∀∇∂∫∑∏√∞≠≈≤≥⊂⊃⊆⊇∪∩→←↔⇄⇌⊢⊥∴∵ℝℕℤℂℚ𝔽𝕋𝔻ℬℒℰ𝒟𝒦𝒥𝒞𝒫ΩΨΦΣΛμπβθζ≪≫{}[]()+-=~<>|*7xkhaLZ\"\n);\n\n// Full class strings so Tailwind can see them at build time.\nexport const GLYPH_PALETTE = [\n  { text: \"text-violet-400\", border: \"border-violet-400/70\" },\n  { text: \"text-orange-400\", border: \"border-orange-400/70\" },\n  { text: \"text-teal-400\", border: \"border-teal-400/70\" },\n  { text: \"text-lime-400\", border: \"border-lime-400/70\" },\n  { text: \"text-sky-400\", border: \"border-sky-400/70\" },\n  { text: \"text-pink-400\", border: \"border-pink-400/70\" },\n  { text: \"text-rose-400\", border: \"border-rose-400/70\" },\n  { text: \"text-cyan-400\", border: \"border-cyan-400/70\" },\n  { text: \"text-amber-400\", border: \"border-amber-400/70\" },\n  { text: \"text-emerald-400\", border: \"border-emerald-400/70\" },\n] as const;\n\nconst SETTLE_MS = 380; // original letter shown in colour before fading to grey\n\nconst pick = <T,>(arr: readonly T[]) =>\n  arr[Math.floor(Math.random() * arr.length)];\n\n/* ------------------------------------------------------------------ */\n/* Types                                                              */\n/* ------------------------------------------------------------------ */\n\ninterface Cell {\n  id: number;\n  ch: string;\n  space: boolean;\n  locked: boolean;\n}\n\ninterface Live {\n  glyph: string;\n  color: number;\n  boxed: boolean;\n  /** 0 normal · 1 bold · 2 italic */\n  weight: 0 | 1 | 2;\n  phase: \"hot\" | \"settle\";\n  until: number;\n}\n\nexport interface GlyphTrailProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  /** Lines of text, centered and set in monospace */\n  lines?: string[];\n  /** A word that stays white and never scrambles */\n  highlight?: string;\n  /** Custom character set to scramble into. Defaults to mathematical glyphs */\n  glyphs?: string | string[];\n  /** Pointer influence radius in px. Default: 30 */\n  radius?: number;\n  /** Average time (ms) a character stays scrambled after pointer leaves. Default: 1200 */\n  lifeMs?: number;\n  className?: string;\n}\n\n/**\n * GlyphTrail\n * Monospace text that scrambles into coloured math symbols under the pointer,\n * then relaxes back to the original letters.\n */\nexport function GlyphTrail({\n  lines = DEFAULT_GLYPH_LINES,\n  highlight = \"corca\",\n  glyphs = DEFAULT_GLYPHS,\n  radius = 30,\n  lifeMs = 1200,\n  className,\n  ...props\n}: GlyphTrailProps) {\n  const reduceMotion = useReducedMotion();\n  const lineList = lines && lines.length > 0 ? lines : DEFAULT_GLYPH_LINES;\n  const glyphPool = useMemo(() => {\n    if (Array.isArray(glyphs)) return glyphs;\n    return Array.from(glyphs);\n  }, [glyphs]);\n\n  const rows = useMemo<Cell[][]>(() => {\n    let id = 0;\n    return lineList.map((line) => {\n      const chars = Array.from(line);\n      const hlChars = highlight ? Array.from(highlight) : [];\n      const at = highlight ? line.indexOf(highlight) : -1;\n      const start = at >= 0 ? Array.from(line.slice(0, at)).length : -1;\n      return chars.map((ch, i) => ({\n        id: id++,\n        ch,\n        space: ch === \" \",\n        locked: start >= 0 && i >= start && i < start + hlChars.length,\n      }));\n    });\n  }, [lineList, highlight]);\n\n  const flat = useMemo(() => rows.flat(), [rows]);\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const cellEls = useRef<(HTMLSpanElement | null)[]>([]);\n  const centers = useRef<{ x: number; y: number }[]>([]);\n  const live = useRef(new Map<number, Live>());\n  const raf = useRef(0);\n  const [, rerender] = useReducer((n: number) => n + 1, 0);\n\n  /* Cache each character's centre relative to the container */\n  const measure = useCallback(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const rb = root.getBoundingClientRect();\n    centers.current = flat.map((_, i) => {\n      const el = cellEls.current[i];\n      if (!el) return { x: -1e5, y: -1e5 };\n      const r = el.getBoundingClientRect();\n      return {\n        x: r.left - rb.left + r.width / 2,\n        y: r.top - rb.top + r.height / 2,\n      };\n    });\n  }, [flat]);\n\n  useIsomorphicLayoutEffect(() => {\n    measure();\n    const root = rootRef.current;\n    const ro = root ? new ResizeObserver(measure) : null;\n    if (root && ro) ro.observe(root);\n    if (typeof document !== \"undefined\" && document.fonts?.ready) {\n      document.fonts.ready.then(measure);\n    }\n    return () => ro?.disconnect();\n  }, [measure]);\n\n  useEffect(() => () => cancelAnimationFrame(raf.current), []);\n\n  /* Decay loop — only runs while something is active */\n  const tick = useCallback(() => {\n    const now = performance.now();\n    let changed = false;\n\n    live.current.forEach((cell, id) => {\n      if (now < cell.until) return;\n      if (cell.phase === \"hot\") {\n        cell.phase = \"settle\";\n        cell.glyph = flat[id]?.ch ?? \"\"; // original letter, still coloured\n        cell.boxed = false;\n        cell.until = now + SETTLE_MS;\n      } else {\n        live.current.delete(id); // colour fades back to grey\n      }\n      changed = true;\n    });\n\n    if (changed) rerender();\n    raf.current = live.current.size ? requestAnimationFrame(tick) : 0;\n  }, [flat]);\n\n  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (reduceMotion || !rootRef.current) return;\n    const rb = rootRef.current.getBoundingClientRect();\n    const px = e.clientX - rb.left;\n    const py = e.clientY - rb.top;\n    const r2 = radius * radius;\n    const now = performance.now();\n    const life = () => lifeMs * (0.7 + Math.random() * 0.8);\n    let changed = false;\n\n    centers.current.forEach((c, i) => {\n      const cell = flat[i];\n      if (!cell || cell.space || cell.locked) return;\n      const dx = c.x - px;\n      const dy = c.y - py;\n      if (dx * dx + dy * dy > r2) return;\n\n      const existing = live.current.get(i);\n      if (existing && existing.phase === \"hot\") {\n        existing.until = now + life();\n        if (Math.random() < 0.04) {\n          existing.glyph = pick(glyphPool);\n          changed = true;\n        }\n        return;\n      }\n\n      live.current.set(i, {\n        glyph: pick(glyphPool),\n        color: Math.floor(Math.random() * GLYPH_PALETTE.length),\n        boxed: Math.random() < 0.45,\n        weight: (Math.random() < 0.3\n          ? 1\n          : Math.random() < 0.2\n          ? 2\n          : 0) as 0 | 1 | 2,\n        phase: \"hot\",\n        until: now + life(),\n      });\n      changed = true;\n    });\n\n    if (changed) rerender();\n    if (!raf.current && live.current.size)\n      raf.current = requestAnimationFrame(tick);\n  };\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"img\"\n      aria-label={lineList.join(\", \")}\n      onPointerMove={onPointerMove}\n      className={cn(\n        \"relative select-none bg-black px-8 py-14 font-mono text-2xl leading-snug text-neutral-500\",\n        className\n      )}\n      {...props}\n    >\n      {rows.map((row, r) => (\n        <div key={r} className=\"flex justify-center whitespace-pre\">\n          {row.map((cell) => {\n            const lv = live.current.get(cell.id);\n            const color = lv ? GLYPH_PALETTE[lv.color] : null;\n            const glyph = lv?.glyph ?? cell.ch;\n\n            return (\n              <span\n                key={cell.id}\n                ref={(el) => {\n                  cellEls.current[cell.id] = el;\n                }}\n                className=\"relative inline-block w-[1ch] text-center\"\n              >\n                <motion.span\n                  key={glyph}\n                  initial={lv ? { opacity: 0.35, scale: 0.8 } : false}\n                  animate={{ opacity: 1, scale: 1 }}\n                  transition={{ duration: 0.12, ease: \"easeOut\" }}\n                  className={cn(\n                    \"inline-block transition-colors\",\n                    lv?.phase === \"hot\" ? \"duration-100\" : \"duration-700\",\n                    cell.locked\n                      ? \"text-white\"\n                      : color\n                      ? color.text\n                      : \"text-neutral-500\",\n                    lv?.weight === 1 && \"font-bold\",\n                    lv?.weight === 2 && \"italic\"\n                  )}\n                >\n                  {glyph}\n                </motion.span>\n\n                {!cell.space && !cell.locked && (\n                  <motion.span\n                    aria-hidden=\"true\"\n                    initial={false}\n                    animate={{\n                      opacity: lv?.boxed ? 1 : 0,\n                      scale: lv?.boxed ? 1 : 0.8,\n                    }}\n                    transition={{ duration: 0.18, ease: \"easeOut\" }}\n                    className={cn(\n                      \"pointer-events-none absolute inset-y-0 -inset-x-px rounded-[5px] border\",\n                      color ? color.border : \"border-transparent\"\n                    )}\n                  />\n                )}\n              </span>\n            );\n          })}\n        </div>\n      ))}\n    </div>\n  );\n}\n\nexport default GlyphTrail;\n",
      "type": "registry:ui",
      "target": "components/ui/glyph-trail.tsx"
    },
    {
      "path": "lib/utils.ts",
      "content": "import { type ClassValue, clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n\nexport async function copyToClipboard(text: string): Promise<boolean> {\n  if (typeof window === \"undefined\") return false;\n\n  try {\n    if (navigator.clipboard && window.isSecureContext) {\n      await navigator.clipboard.writeText(text);\n      return true;\n    }\n\n    const textArea = document.createElement(\"textarea\");\n    textArea.value = text;\n    textArea.style.position = \"fixed\";\n    textArea.style.left = \"-999999px\";\n    textArea.style.top = \"-999999px\";\n    document.body.appendChild(textArea);\n    textArea.focus();\n    textArea.select();\n    const succeeded = document.execCommand(\"copy\");\n    textArea.remove();\n    return succeeded;\n  } catch {\n    return false;\n  }\n}\n",
      "type": "registry:lib",
      "target": "lib/utils.ts"
    }
  ]
}
