{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-scramble",
  "type": "registry:ui",
  "title": "Text Scramble",
  "description": "Decodes left to right in fixed time, only where characters changed.",
  "categories": [
    "content"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json"
  ],
  "files": [
    {
      "path": "components/ui/text-scramble.tsx",
      "content": "\"use client\";\nimport { useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\n\nconst UPPER = \"ABCDEFGHIJKLMNOPQRSTUVWXYZ\";\nconst LOWER = \"abcdefghijklmnopqrstuvwxyz\";\nconst DIGITS = \"0123456789\";\n/** How often each scrambling character picks a new glyph, in ms. Faster reads as noise, slower as stutter. */\nconst GLYPH_MS = 55;\n\n// Server and client render identically on the first pass; layout effects run\n// after hydration, so the isomorphic version only skips the server warning.\nconst useIsoLayoutEffect = typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\nexport type ScrambleCell =\n  /** Settled: the real character. */\n  | { char: string; show: \"final\" }\n  /** The wave hasn't reached it: the character that was there before, or nothing. */\n  | { char: string; show: \"before\"; glyph: string }\n  /** Decoding: a stand-in glyph of the same kind (letter, digit, case). */\n  | { char: string; show: \"glyph\"; glyph: string };\n\ntype Run = { id: number; from: string; seed: number };\n\n// A cheap integer hash, so a glyph is a pure function of (character, tick, run):\n// rendering stays pure and every frame of a run is reproducible.\nfunction hash(n: number) {\n  n = Math.imul(n ^ (n >>> 16), 0x45d9f3b);\n  n = Math.imul(n ^ (n >>> 16), 0x45d9f3b);\n  return (n ^ (n >>> 16)) >>> 0;\n}\n\nfunction pool(char: string, charset?: string) {\n  if (/\\s/.test(char)) return null;\n  if (charset) return charset;\n  if (DIGITS.includes(char)) return DIGITS;\n  if (UPPER.includes(char)) return UPPER;\n  if (LOWER.includes(char)) return LOWER;\n  // Punctuation and symbols (_ - . / :) hold still: they are the string's structure.\n  return null;\n}\n\n/**\n * The scramble on its own: give it the text, get back one cell per character\n * for the current frame. Changing `text` decodes from the old string into the\n * new one; `play(from)` runs it on demand (\"\" decodes from nothing).\n */\nexport function useScramble(\n  text: string,\n  { duration = 0.8, charset, scrambleOnChange = true, onSettled }: { duration?: number; charset?: string; scrambleOnChange?: boolean; onSettled?: () => void } = {},\n) {\n  const reduce = useReducedMotion();\n  const [run, setRun] = useState<Run | null>(null);\n  const [elapsed, setElapsed] = useState(0);\n  const [prevText, setPrevText] = useState(text);\n  const settled = useRef(onSettled);\n  useEffect(() => {\n    settled.current = onSettled;\n  });\n\n  // A new string decodes from whatever was showing before it.\n  if (text !== prevText) {\n    setPrevText(text);\n    if (scrambleOnChange && !reduce) {\n      setRun((r) => ({ id: (r?.id ?? 0) + 1, from: prevText, seed: (r?.seed ?? 7) * 31 + text.length }));\n      setElapsed(0);\n    }\n  }\n\n  const play = useCallback(\n    (from = \"\") => {\n      if (reduce) return settled.current?.();\n      setRun((r) => ({ id: (r?.id ?? 0) + 1, from, seed: Math.floor(Math.random() * 1e9) }));\n      setElapsed(0);\n    },\n    [reduce],\n  );\n\n  const total = duration * 1000;\n  useEffect(() => {\n    if (!run) return;\n    let raf = 0;\n    let start = 0;\n    const frame = (now: number) => {\n      start ||= now;\n      const t = now - start;\n      if (t >= total) {\n        setRun(null);\n        setElapsed(0);\n        settled.current?.();\n        return;\n      }\n      setElapsed(t);\n      raf = requestAnimationFrame(frame);\n    };\n    raf = requestAnimationFrame(frame);\n    return () => cancelAnimationFrame(raf);\n  }, [run, total]);\n\n  const chars = Array.from(text);\n  const from = run ? Array.from(run.from) : [];\n  // The wave spans only the stretch that changes, so \"sk_demo_••••4f2a\" to the\n  // full key spends the whole duration on the secret, not the fixed prefix.\n  let lo = -1;\n  let hi = -1;\n  if (run)\n    chars.forEach((c, i) => {\n      if (from[i] === c) return;\n      if (lo < 0) lo = i;\n      hi = i;\n    });\n  const cells: ScrambleCell[] = chars.map((char, i) => {\n    if (!run) return { char, show: \"final\" };\n    const before = from[i] ?? \"\";\n    // Characters that were already right never move.\n    if (before === char) return { char, show: \"final\" };\n    // A wave left to right: each character starts scrambling at 40% of its\n    // position through the run and settles 60% of the run later, so the last\n    // one lands exactly at `duration` whatever the length.\n    const p = hi > lo ? (i - lo) / (hi - lo) : 0;\n    const startAt = p * total * 0.4;\n    if (elapsed >= startAt + total * 0.6) return { char, show: \"final\" };\n    const kinds = pool(char, charset);\n    // Punctuation has nothing to scramble through: it waits, then swaps when its turn settles.\n    if (elapsed < startAt || !kinds) return { char, show: \"before\", glyph: before };\n    const tick = Math.floor(elapsed / GLYPH_MS);\n    return { char, show: \"glyph\", glyph: kinds[hash(run.seed + i * 7919 + tick * 104729) % kinds.length] };\n  });\n\n  return { cells, scrambling: run !== null, play };\n}\n\ntype Tag = \"span\" | \"p\" | \"div\" | \"code\" | \"h1\" | \"h2\" | \"h3\" | \"h4\";\n\nexport type TextScrambleProps = Omit<React.HTMLAttributes<HTMLElement>, \"children\"> & {\n  /** The text to show. Changing it decodes from the old string into the new one. */\n  children: string;\n  as?: Tag;\n  /** Decode from nothing when it scrolls into view, as it mounts, or never (only on change). */\n  trigger?: \"view\" | \"mount\" | \"none\";\n  /** Seconds for a whole run, whatever the length of the text. */\n  duration?: number;\n  /** Glyphs to scramble through. By default each character scrambles within its own kind: digits, capitals or lowercase. */\n  charset?: string;\n  /** Decode from the previous text when `children` changes. */\n  scrambleOnChange?: boolean;\n  /** Called each time a run settles on the final text. */\n  onSettled?: () => void;\n  ref?: React.Ref<HTMLElement>;\n};\n\n/**\n * Text that decodes from random glyphs into the real string, left to right,\n * in a fixed time. Each character keeps the width of its final glyph, so the\n * line never jitters, and the real string is always there for assistive tech.\n */\nexport function TextScramble({\n  children,\n  as: Tag = \"span\",\n  trigger = \"view\",\n  duration = 0.8,\n  charset,\n  scrambleOnChange = true,\n  onSettled,\n  className,\n  ref,\n  ...rest\n}: TextScrambleProps) {\n  const reduce = useReducedMotion();\n  const self = useRef<HTMLElement>(null);\n  const { cells, scrambling, play } = useScramble(children, { duration, charset, scrambleOnChange, onSettled });\n\n  // \"Armed\" lifts the pre-reveal hiding. It is set in the same frame the first\n  // run renders, so the settled text never flashes before it decodes.\n  useIsoLayoutEffect(() => {\n    if (scrambling || reduce) self.current?.setAttribute(\"data-armed\", \"\");\n  }, [scrambling, reduce]);\n\n  useEffect(() => {\n    const el = self.current;\n    if (!el || trigger === \"none\" || el.hasAttribute(\"data-armed\") || reduce) return;\n    if (trigger === \"mount\") {\n      const raf = requestAnimationFrame(() => play(\"\"));\n      return () => cancelAnimationFrame(raf);\n    }\n    const io = new IntersectionObserver(([entry]) => {\n      const above = entry.boundingClientRect.bottom < (entry.rootBounds?.top ?? 0);\n      if (!entry.isIntersecting && !above) return;\n      io.disconnect();\n      if (above) el.setAttribute(\"data-armed\", \"\");\n      else play(\"\");\n    });\n    io.observe(el);\n    return () => io.disconnect();\n  }, [trigger, reduce, play]);\n\n  // Words are grouped so a line only ever breaks between words, never inside one.\n  const words: { cells: ScrambleCell[]; start: number }[] = [];\n  let index = 0;\n  for (const part of children.split(/(\\s+)/)) {\n    if (part) words.push({ cells: cells.slice(index, index + Array.from(part).length), start: index });\n    index += Array.from(part).length;\n  }\n\n  return (\n    <>\n      <Tag\n        ref={(node: HTMLElement | null) => {\n          self.current = node;\n          if (typeof ref === \"function\") return ref(node);\n          if (ref) (ref as React.RefObject<HTMLElement | null>).current = node;\n        }}\n        data-text-scramble=\"\"\n        data-state={scrambling ? \"scrambling\" : \"settled\"}\n        className={cn(\n          trigger !== \"none\" && \"motion-safe:[&:not([data-armed])_[data-cell]]:opacity-0\",\n          \"print:[&_[data-cell]]:opacity-100!\",\n          className,\n        )}\n        {...rest}\n      >\n        <span className=\"sr-only select-none\">{children}</span>\n        <span aria-hidden>\n          {words.map((word) =>\n            /^\\s+$/.test(word.cells.map((c) => c.char).join(\"\")) ? (\n              word.cells.map((c) => c.char).join(\"\")\n            ) : (\n              <span key={word.start} className=\"whitespace-nowrap\">\n                {word.cells.map((cell, i) => (\n                  <span key={i} data-cell=\"\" data-show={cell.show} className=\"relative inline-block\">\n                    {/* The real character always holds the cell's width; stand-ins draw over it. */}\n                    <span className={cell.show === \"final\" ? undefined : \"opacity-0\"}>{cell.char}</span>\n                    {cell.show !== \"final\" && (\n                      <span className={cn(\"absolute inset-0 text-center\", cell.show === \"glyph\" && \"opacity-45\")}>{cell.glyph}</span>\n                    )}\n                  </span>\n                ))}\n              </span>\n            ),\n          )}\n        </span>\n      </Tag>\n      <noscript>\n        <style>{\"[data-text-scramble] [data-cell]{opacity:1!important}\"}</style>\n      </noscript>\n    </>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/text-scramble"
}
