{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"text-scramble","type":"registry:component","title":"Text Scramble","description":"Scrambled glyphs resolve left-to-right into the real text — on view, on hover, or whenever the text prop changes. Width stays stable mid-scramble; reduced-motion shows the final text instantly.","author":"UI Lab","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/text-scramble.tsx","type":"registry:component","target":"@components/motion/text-scramble.tsx","content":"\"use client\";\n// ui-lab-ten.vercel.app/components/motion/text-scramble\n\n// Ported from motion-anything (nexu-io, Apache-2.0); upstream effect: reactbits.dev \"Decrypted Text\", redistributed with permission.\n\nimport { useInView, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface TextScrambleProps {\n  /** The real text. Changing it plays a scramble decode from the old value to the new one. */\n  text: string;\n  /** \"view\" decodes once on scroll into view (default). \"hover\" replays on pointer hover; touch devices fall back to \"view\". */\n  trigger?: \"view\" | \"hover\";\n  /** Total decode time in ms. */\n  duration?: number;\n  /** Glyphs shown while a character is still unresolved. */\n  charset?: string;\n  className?: string;\n}\n\nconst DEFAULT_CHARSET = \"!<>-_\\\\/[]{}=+*^?#\";\n\nexport function TextScramble({\n  text,\n  trigger = \"view\",\n  duration = 700,\n  charset = DEFAULT_CHARSET,\n  className,\n}: TextScrambleProps) {\n  const ref = useRef<HTMLSpanElement>(null);\n  const reduce = useReducedMotion();\n  const canHover = useHoverCapable();\n  const inView = useInView(ref, { once: true, amount: 0.4 });\n\n  // Touch devices get phantom hover states that stick after tap — degrade to view.\n  const effectiveTrigger = trigger === \"hover\" && !canHover ? \"view\" : trigger;\n\n  const [display, setDisplay] = useState(text);\n  const prevTextRef = useRef(text);\n  const enteredRef = useRef(false);\n  const rafRef = useRef<number | null>(null);\n\n  const scrambleTo = useCallback(\n    (from: string, to: string) => {\n      if (rafRef.current != null) cancelAnimationFrame(rafRef.current);\n      if (reduce) {\n        setDisplay(to);\n        return;\n      }\n      const len = Math.max(from.length, to.length);\n      if (len === 0) {\n        setDisplay(\"\");\n        return;\n      }\n      const totalFrames = Math.max(1, Math.round((duration / 1000) * 60));\n      let frame = 0;\n      const tick = () => {\n        const revealed = (frame / totalFrames) * len;\n        let out = \"\";\n        for (let i = 0; i < len; i++) {\n          const targetChar = to[i] ?? \"\";\n          if (i < revealed) {\n            out += targetChar;\n          } else if (targetChar === \" \") {\n            out += \" \";\n          } else {\n            out += charset[Math.floor(Math.random() * charset.length)];\n          }\n        }\n        setDisplay(out);\n        if (revealed >= len) {\n          setDisplay(to);\n          rafRef.current = null;\n          return;\n        }\n        frame += 1;\n        rafRef.current = requestAnimationFrame(tick);\n      };\n      tick();\n    },\n    [charset, duration, reduce],\n  );\n\n  // Entrance: decode once when the element scrolls into view.\n  useEffect(() => {\n    if (effectiveTrigger !== \"view\" || reduce) return;\n    if (!inView || enteredRef.current) return;\n    enteredRef.current = true;\n    scrambleTo(prevTextRef.current, text);\n  }, [effectiveTrigger, inView, reduce, scrambleTo, text]);\n\n  // Replay a scramble whenever the text prop itself changes, from the old value to the new one.\n  useEffect(() => {\n    if (prevTextRef.current === text) return;\n    const from = prevTextRef.current;\n    prevTextRef.current = text;\n    if (effectiveTrigger === \"view\" && !enteredRef.current) {\n      // Not on screen yet — swap silently, the entrance effect will decode it in later.\n      setDisplay(text);\n      return;\n    }\n    scrambleTo(from, text);\n  }, [text, effectiveTrigger, scrambleTo]);\n\n  useEffect(() => {\n    return () => {\n      if (rafRef.current != null) cancelAnimationFrame(rafRef.current);\n    };\n  }, []);\n\n  const hoverHandlers =\n    effectiveTrigger === \"hover\" ? { onMouseEnter: () => scrambleTo(text, text) } : {};\n\n  return (\n    <span\n      ref={ref}\n      {...hoverHandlers}\n      className={cn(\"relative inline-block whitespace-nowrap align-baseline\", className)}\n    >\n      <span className=\"sr-only\">{text}</span>\n      {/* Invisible sizer: reserves box width from the final text so the scramble never jitters the layout. */}\n      <span aria-hidden=\"true\" className=\"invisible\">\n        {text}\n      </span>\n      <span aria-hidden=\"true\" className=\"absolute inset-0 whitespace-nowrap\">\n        {display}\n      </span>\n    </span>\n  );\n}\n"},{"path":"lib/hooks/use-hover-capable.ts","type":"registry:hook","target":"@lib/hooks/use-hover-capable.ts","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}