{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scramble-text",
  "type": "registry:ui",
  "title": "Scramble Text",
  "description": "A string that resolves out of random glyphs, left to right, on mount, on scroll, or on hover.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/loomui/scramble-text.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface ScrambleTextProps extends Omit<\n  React.ComponentProps<\"span\">,\n  \"children\"\n> {\n  /** The string the run resolves to. */\n  text: string\n  /** Glyphs drawn in a slot that has not settled yet. */\n  characters?: string\n  /** Milliseconds per scramble frame. */\n  speed?: number\n  /** Frames a slot scrambles before it settles. */\n  cycles?: number\n  /** Frames between one slot settling and the next. */\n  stagger?: number\n  /** What starts a run. `hover` starts a fresh one on every enter. */\n  trigger?: \"mount\" | \"view\" | \"hover\"\n  /** Render the finished text with no scramble. */\n  disabled?: boolean\n}\n\nconst DEFAULT_CHARACTERS = \"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&@*<>/\"\n\n// `useEffect` would let the finished text paint for a frame before the first\n// scramble frame replaced it, and `useLayoutEffect` alone warns during SSR.\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? React.useEffect : React.useLayoutEffect\n\nexport function ScrambleText({\n  text,\n  characters = DEFAULT_CHARACTERS,\n  speed = 45,\n  cycles = 8,\n  stagger = 2,\n  trigger = \"mount\",\n  disabled = false,\n  className,\n  ...props\n}: ScrambleTextProps) {\n  const ref = React.useRef<HTMLSpanElement>(null)\n\n  // The first render is the finished text, so the server and the client agree\n  // and the component degrades to plain text without JavaScript.\n  const [display, setDisplay] = React.useState(text)\n  const [run, setRun] = React.useState(0)\n\n  const start = React.useCallback(() => setRun((value) => value + 1), [])\n\n  React.useEffect(() => {\n    if (trigger === \"mount\") {\n      start()\n      return\n    }\n    if (trigger !== \"view\") {\n      return\n    }\n\n    const node = ref.current\n    if (!node) {\n      return\n    }\n\n    const observer = new IntersectionObserver(\n      (entries) => {\n        if (entries.some((entry) => entry.isIntersecting)) {\n          start()\n          observer.disconnect()\n        }\n      },\n      { threshold: 0.3 }\n    )\n    observer.observe(node)\n    return () => observer.disconnect()\n  }, [trigger, text, start])\n\n  useIsomorphicLayoutEffect(() => {\n    if (run === 0) {\n      return\n    }\n    if (\n      disabled ||\n      window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n    ) {\n      setDisplay(text)\n      return\n    }\n\n    const slots = Array.from(text)\n    const pool = characters.length > 0 ? characters : DEFAULT_CHARACTERS\n    const last = (slots.length - 1) * stagger + cycles\n    let frame = 0\n\n    const draw = () => {\n      setDisplay(\n        slots\n          .map((slot, index) => {\n            // Whitespace never scrambles. Word boundaries are what makes a\n            // half-resolved line still read as a line.\n            if (slot.trim() === \"\") {\n              return slot\n            }\n            if (frame >= index * stagger + cycles) {\n              return slot\n            }\n            return pool[Math.floor(Math.random() * pool.length)]\n          })\n          .join(\"\")\n      )\n    }\n\n    draw()\n    const timer = setInterval(() => {\n      frame += 1\n      draw()\n      if (frame >= last) {\n        clearInterval(timer)\n      }\n    }, speed)\n\n    return () => clearInterval(timer)\n  }, [run, text, characters, speed, cycles, stagger, disabled])\n\n  return (\n    <span\n      ref={ref}\n      data-slot=\"scramble-text\"\n      onPointerEnter={trigger === \"hover\" ? start : undefined}\n      className={cn(\"inline-grid\", className)}\n      {...props}\n    >\n      {/* The finished text reserves the width, so a run cannot reflow what is\n          sitting next to it. */}\n      <span\n        aria-hidden=\"true\"\n        className=\"invisible col-start-1 row-start-1 whitespace-pre\"\n      >\n        {text}\n      </span>\n      {/* Announcing every frame would be noise, so the real string is read\n          once and the scramble itself is decoration. */}\n      <span className=\"sr-only\">{text}</span>\n      <span\n        aria-hidden=\"true\"\n        className=\"col-start-1 row-start-1 whitespace-pre\"\n      >\n        {display}\n      </span>\n    </span>\n  )\n}\n",
      "type": "registry:ui"
    }
  ]
}