{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-scramble",
  "type": "registry:ui",
  "title": "Text Scramble",
  "description": "Text that resolves through a flurry of random glyphs — without ever changing width.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/text-scramble.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type TextScrambleTrigger = \"hover\" | \"view\" | \"mount\";\nexport type TextScrambleOrder = \"forward\" | \"random\" | \"center\";\n\nexport interface TextScrambleProps extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n  /** Final text. Changing it scrambles from the current text to the new one. */\n  text?: string;\n  /** Element to render. */\n  as?: \"span\" | \"p\" | \"h1\" | \"h2\" | \"h3\" | \"div\";\n  /** Pool of glyphs shown while a character is unresolved. */\n  characters?: string;\n  /** Total time until the last character resolves, in ms. */\n  duration?: number;\n  /** How often an unresolved glyph changes, in ms. */\n  interval?: number;\n  /** What starts the scramble (text changes always scramble). */\n  trigger?: TextScrambleTrigger;\n  /** Order in which characters settle. */\n  order?: TextScrambleOrder;\n  /** Colour of unresolved glyphs (any CSS colour). */\n  accent?: string;\n}\n\n/* --------------------------- reduced-motion hook --------------------------- */\n\nconst REDUCED_QUERY = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribeReducedMotion(onChange: () => void) {\n  const mq = window.matchMedia(REDUCED_QUERY);\n  mq.addEventListener(\"change\", onChange);\n  return () => mq.removeEventListener(\"change\", onChange);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(REDUCED_QUERY).matches,\n    () => false,\n  );\n}\n\n/* --------------------------------- engine --------------------------------- */\n\ninterface ScrambleOptions {\n  characters: string;\n  duration: number;\n  interval: number;\n  order: TextScrambleOrder;\n}\n\n/**\n * Runs one scramble over every cell inside `root`, writing glyphs straight into the DOM\n * from requestAnimationFrame (no React state per frame). Returns a cancel function.\n */\nfunction runScramble(root: HTMLElement, { characters, duration, interval, order }: ScrambleOptions) {\n  const cells = Array.from(root.querySelectorAll<HTMLElement>(\"[data-ts-cell]\"));\n  const glyphs = cells.map((cell) => cell.querySelector<HTMLElement>(\"[data-ts-glyph]\"));\n  const pool = Array.from(characters);\n  const count = cells.length;\n  if (!count || !pool.length) return () => {};\n\n  const randomGlyph = () => pool[Math.floor(Math.random() * pool.length)];\n  const mid = (count - 1) / 2;\n\n  // Every cell starts scrambling at once and settles somewhere in the last 75% of the duration.\n  const settleAt = cells.map((_, i) => {\n    let position: number;\n    if (order === \"random\") position = Math.random();\n    else if (order === \"center\") position = mid === 0 ? 1 : Math.abs(i - mid) / mid;\n    else position = count === 1 ? 1 : i / (count - 1);\n    const jitter = order === \"random\" ? 0 : (Math.random() - 0.5) * 0.08;\n    return duration * Math.min(1, Math.max(0.25, 0.25 + 0.75 * position + jitter));\n  });\n\n  const reset = (i: number) => {\n    cells[i].removeAttribute(\"data-scrambling\");\n    const glyph = glyphs[i];\n    if (glyph) glyph.textContent = \"\";\n  };\n\n  cells.forEach((cell, i) => {\n    cell.setAttribute(\"data-scrambling\", \"\");\n    const glyph = glyphs[i];\n    if (glyph) glyph.textContent = randomGlyph();\n  });\n\n  const start = performance.now();\n  let lastSwap = start;\n  let frame = requestAnimationFrame(function tick() {\n    const now = performance.now();\n    const elapsed = now - start;\n    const swap = now - lastSwap >= interval;\n    if (swap) lastSwap = now;\n    let pending = 0;\n    for (let i = 0; i < count; i++) {\n      if (!cells[i].hasAttribute(\"data-scrambling\")) continue;\n      if (elapsed >= settleAt[i]) {\n        reset(i);\n        continue;\n      }\n      pending += 1;\n      const glyph = glyphs[i];\n      if (swap && glyph) glyph.textContent = randomGlyph();\n    }\n    if (pending) frame = requestAnimationFrame(tick);\n  });\n\n  return () => {\n    cancelAnimationFrame(frame);\n    for (let i = 0; i < count; i++) reset(i);\n  };\n}\n\nfunction stopScramble(cancelRef: React.RefObject<(() => void) | null>) {\n  cancelRef.current?.();\n  cancelRef.current = null;\n}\n\n/* -------------------------------- component -------------------------------- */\n\n/**\n * Text that resolves through a flurry of random glyphs. Each character sits in a cell sized\n * by the real final glyph, so the line never changes width while scrambling — in any font.\n */\nexport function TextScramble({\n  text = \"Signal acquired\",\n  as = \"span\",\n  characters = \"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&*+/<>\",\n  duration = 1000,\n  interval = 45,\n  trigger = \"hover\",\n  order = \"forward\",\n  accent = \"#FF7448\",\n  className = \"\",\n  style,\n  onPointerEnter,\n  onFocus,\n  ...props\n}: TextScrambleProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const visualRef = React.useRef<HTMLSpanElement>(null);\n  const cancelRef = React.useRef<(() => void) | null>(null);\n  const previousTextRef = React.useRef<string | null>(null);\n  const Tag = as as React.ElementType;\n\n  const start = () => {\n    const root = visualRef.current;\n    if (reduceMotion || !root) return;\n    cancelRef.current?.();\n    cancelRef.current = runScramble(root, { characters, duration, interval, order });\n  };\n  // Effects read the latest options without re-subscribing on every render.\n  const scramble = React.useEffectEvent(start);\n\n  // Mount and in-view triggers.\n  React.useEffect(() => {\n    const root = visualRef.current;\n    if (reduceMotion || !root) return;\n    if (trigger === \"mount\") {\n      scramble();\n      return () => stopScramble(cancelRef);\n    }\n    if (trigger !== \"view\") return;\n    const observer = new IntersectionObserver(\n      (entries) => {\n        if (entries.some((entry) => entry.isIntersecting)) {\n          observer.disconnect();\n          scramble();\n        }\n      },\n      { threshold: 0.5 },\n    );\n    observer.observe(root);\n    return () => {\n      observer.disconnect();\n      stopScramble(cancelRef);\n    };\n    // Re-arm when the timing changes so the new settings can be seen.\n  }, [trigger, reduceMotion, characters, duration, interval, order]);\n\n  // A new `text` always scrambles into place (but not on the first render).\n  React.useEffect(() => {\n    const previous = previousTextRef.current;\n    previousTextRef.current = text;\n    if (previous === null || previous === text) return;\n    scramble();\n  }, [text]);\n\n  React.useEffect(() => () => stopScramble(cancelRef), []);\n\n  const handlePointerEnter = (event: React.PointerEvent<HTMLElement>) => {\n    onPointerEnter?.(event);\n    if (trigger === \"hover\") start();\n  };\n\n  const handleFocus = (event: React.FocusEvent<HTMLElement>) => {\n    onFocus?.(event);\n    if (trigger === \"hover\") start();\n  };\n\n  // Keep whitespace as real text so lines can still wrap between words.\n  const tokens = text.split(/(\\s+)/).filter(Boolean);\n\n  return (\n    <Tag\n      className={`relative ${className}`}\n      style={{ [\"--ts-accent\" as string]: accent, ...style }}\n      onPointerEnter={handlePointerEnter}\n      onFocus={handleFocus}\n      {...props}\n    >\n      <span className=\"sr-only\">{text}</span>\n      <span ref={visualRef} aria-hidden=\"true\">\n        {tokens.map((token, t) =>\n          /^\\s+$/.test(token) ? (\n            \" \"\n          ) : (\n            <span key={t} className=\"inline-block whitespace-nowrap\">\n              {Array.from(token).map((char, c) => (\n                <span key={c} data-ts-cell=\"\" className=\"relative inline-block\">\n                  {/* The final glyph reserves the width; it is hidden while the cell scrambles. */}\n                  <span className=\"[[data-scrambling]>&]:invisible\">{char}</span>\n                  <span\n                    data-ts-glyph=\"\"\n                    className=\"pointer-events-none absolute inset-0 flex items-center justify-center text-[var(--ts-accent)]\"\n                  />\n                </span>\n              ))}\n            </span>\n          ),\n        )}\n      </span>\n    </Tag>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "docs": "Documentation: https://wancraft.my/components/text-scramble"
}
