{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scramble-text",
  "title": "Scramble Text",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Decodes text in place: each character cycles through random glyphs before settling, left to right. Plays on mount, in view or on hover, keeps its final width, and exposes the final text to screen readers.",
  "dependencies": [
    "motion@^12"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/scramble-text.tsx",
      "content": "// Ballmac UI: Scramble Text. https://ui.ballmac.com/components/scramble-text\n\"use client\"\n\nimport * as React from \"react\"\nimport { useInView } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\n\ntype ScrambleTextElement = \"span\" | \"p\" | \"div\" | \"h1\" | \"h2\" | \"h3\" | \"h4\" | \"code\"\n\ntype ScrambleTextProps = Omit<React.ComponentProps<\"span\">, \"children\"> & {\n  /** The final text. */\n  children: string\n  /** The element to render. */\n  as?: ScrambleTextElement\n  /** When to play: on mount, when scrolled into view (once), or on every hover and keyboard focus. */\n  trigger?: \"mount\" | \"inView\" | \"hover\"\n  /** Milliseconds from the first scrambled frame until the last character settles. */\n  duration?: number\n  /** Milliseconds to wait before starting. */\n  delay?: number\n  /** Milliseconds between frames; lower values cycle glyphs faster. */\n  speed?: number\n  /** Glyphs to cycle through before each character resolves. */\n  characters?: string\n  /** Use a monospace font so the width never changes while scrambling. */\n  mono?: boolean\n  /** Called after the text has fully resolved. */\n  onComplete?: () => void\n}\n\nconst REDUCED_QUERY = \"(prefers-reduced-motion: reduce)\"\n\nfunction subscribeReducedMotion(onChange: () => void) {\n  const media = window.matchMedia(REDUCED_QUERY)\n  media.addEventListener(\"change\", onChange)\n  return () => media.removeEventListener(\"change\", onChange)\n}\n\n/** Reduced-motion preference that is false on the server and during hydration, so markup always matches. */\nfunction useReducedMotionSafe() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(REDUCED_QUERY).matches,\n    () => false\n  )\n}\n\nconst GLYPHS = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!<>-_/[]{}=+*^?\"\n\nfunction ScrambleText({\n  children: text,\n  as = \"span\",\n  trigger = \"mount\",\n  duration = 900,\n  delay = 0,\n  speed = 40,\n  characters = GLYPHS,\n  mono = false,\n  onComplete,\n  className,\n  onPointerEnter,\n  onFocus,\n  ...props\n}: ScrambleTextProps) {\n  const Comp = as as React.ElementType\n  const rootRef = React.useRef<HTMLElement>(null)\n  const outputRef = React.useRef<HTMLSpanElement>(null)\n  const timers = React.useRef<{ interval?: number; timeout?: number }>({})\n  const reduceMotion = useReducedMotionSafe()\n  const inView = useInView(rootRef, { once: true, margin: \"0px 0px -10% 0px\" })\n  const completeRef = React.useRef(onComplete)\n  completeRef.current = onComplete\n\n  const stop = React.useCallback(() => {\n    window.clearInterval(timers.current.interval)\n    window.clearTimeout(timers.current.timeout)\n  }, [])\n\n  const play = React.useCallback(() => {\n    const output = outputRef.current\n    if (!output || reduceMotion) return\n    stop()\n    const chars = Array.from(text)\n    const frames = Math.max(1, Math.round(duration / speed))\n    // Left to right with a little jitter, so it reads as decoding rather than a wipe.\n    const settleAt = chars.map((_, i) => 1 + Math.round((i / Math.max(chars.length, 1)) * frames * 0.7 + Math.random() * frames * 0.3))\n    const glyph = () => characters[Math.floor(Math.random() * characters.length)] ?? \"\"\n    let frame = 0\n    const render = () => {\n      output.textContent = chars.map((c, i) => (c.trim() === \"\" || frame >= settleAt[i]! ? c : glyph())).join(\"\")\n    }\n    const start = () => {\n      render()\n      timers.current.interval = window.setInterval(() => {\n        frame++\n        if (frame >= frames) {\n          stop()\n          output.textContent = text\n          completeRef.current?.()\n        } else render()\n      }, speed)\n    }\n    if (delay > 0) {\n      render()\n      timers.current.timeout = window.setTimeout(start, delay)\n    } else start()\n  }, [text, duration, delay, speed, characters, reduceMotion, stop])\n\n  // Layout effect: the first scrambled frame replaces the final text before the browser paints.\n  React.useLayoutEffect(() => {\n    if (reduceMotion) {\n      if (outputRef.current) outputRef.current.textContent = text\n      return\n    }\n    if (trigger === \"mount\" || (trigger === \"inView\" && inView)) play()\n    return stop\n  }, [trigger, inView, play, stop, reduceMotion, text])\n\n  return (\n    <Comp\n      ref={rootRef}\n      data-slot=\"scramble-text\"\n      className={cn(\"relative inline-block\", mono && \"font-mono\", className)}\n      onPointerEnter={(event: React.PointerEvent<HTMLSpanElement>) => {\n        if (trigger === \"hover\") play()\n        onPointerEnter?.(event)\n      }}\n      onFocus={(event: React.FocusEvent<HTMLSpanElement>) => {\n        if (trigger === \"hover\") play()\n        onFocus?.(event)\n      }}\n      {...props}\n    >\n      <span className=\"sr-only\">{text}</span>\n      <span aria-hidden=\"true\" className=\"relative block\">\n        {/* Invisible final text holds the layout; the scrambled copy is drawn over it. */}\n        <span className=\"invisible\">{text}</span>\n        <span ref={outputRef} data-slot=\"scramble-text-output\" className=\"absolute inset-0\">\n          {text}\n        </span>\n      </span>\n    </Comp>\n  )\n}\n\nexport { ScrambleText, type ScrambleTextProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/scramble-text.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "text",
      "scramble",
      "decode",
      "glitch",
      "terminal",
      "hover",
      "motion"
    ],
    "version": "1.0.0",
    "updated": "2026-09-29",
    "ai": {
      "summary": "<ScrambleText trigger=\"mount\" | \"inView\" | \"hover\">Final text</ScrambleText>. Children must be a string. Frames are written straight to the DOM (no re-renders); an invisible copy of the final text reserves the layout and an sr-only copy is what assistive tech reads.",
      "whenToUse": [
        "Terminal, security or developer-tool moments (status lines, keys, hashes)",
        "Short headings that reveal on scroll",
        "Nav links or buttons with a hover flourish (trigger=\"hover\")"
      ],
      "whenNotToUse": [
        "Paragraphs or anything longer than a line",
        "Text that updates often (the scramble restarts each time)"
      ],
      "composesWith": [
        "terminal",
        "word-rotate"
      ],
      "a11y": [],
      "customization": [
        "duration (default 900 ms), delay, speed (ms per frame, default 40)",
        "characters: glyph set, e.g. \"01\" or hex digits",
        "mono: monospace so the width never shifts",
        "onComplete fires after the final frame; reduced motion shows the final text at once"
      ]
    },
    "examples": [
      "scramble-text-demo",
      "scramble-text-hover"
    ],
    "url": "https://ui.ballmac.com/components/scramble-text"
  },
  "categories": [
    "text"
  ],
  "type": "registry:ui"
}