{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scramble-text",
  "title": "Scramble Text",
  "description": "Noise that settles into the real sentence, from a small seeded engine, with every letter holding its final width so nothing on the page moves.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/split-text.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/scramble-text.tsx",
      "content": "import type * as React from \"react\"\nimport { Fragment, useEffect, useLayoutEffect, useMemo, useRef } from \"react\"\nimport { useInView, useReducedMotion } from \"motion/react\"\nimport { cellGlyph, scrambleDuration, scrambleWords, type ScrambleMode, type ScrambleWord } from \"@/registry/miseui/ui/scramble-math\"\nimport { GlyphCell, WordRow, glyphCells, paintGlyph, useReplayHost, useTimeline, type FramePainter } from \"@/registry/miseui/ui/scramble-play\"\nimport type { SplitOrigin } from \"@/registry/miseui/ui/split-text-math\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype ScrambleTextVariant = \"letters\" | \"mono\" | \"center\" | \"ticker\"\ntype ScrambleTextEffect = \"hover\"\ntype ScrambleTextTrigger = \"view\" | \"mount\"\ntype ScrambleTextTag = \"h1\" | \"h2\" | \"h3\" | \"h4\" | \"p\" | \"span\" | \"div\"\n\nconst DEFAULT_SEED = 7\nconst VIEW_MARGIN = \"0px 0px -10% 0px\"\n\nconst VARIANT_PLAN: Record<ScrambleTextVariant, { mode: ScrambleMode; order: SplitOrigin }> = {\n  letters: { mode: \"letters\", order: \"start\" },\n  mono: { mode: \"symbols\", order: \"start\" },\n  center: { mode: \"letters\", order: \"center\" },\n  ticker: { mode: \"ticker\", order: \"start\" },\n}\n\nfunction createPainter(visual: HTMLElement, words: readonly ScrambleWord[], mode: ScrambleMode, seed: number): FramePainter {\n  const nodes = glyphCells(visual)\n  const cells = words.flatMap((word) => word.cells)\n  const duration = scrambleDuration(words)\n  return (elapsed) => {\n    cells.forEach((cell, position) => paintGlyph(nodes[position], cellGlyph(cell, elapsed, mode, seed)))\n    return elapsed >= duration\n  }\n}\n\ntype ScrambleTextProps = Omit<React.HTMLAttributes<HTMLElement>, \"children\"> & {\n  text: string\n  /** The element to render. A heading stays a heading. */\n  as?: ScrambleTextTag\n  /** `letters` same-case noise in the text's own face, `mono` symbols in a monospace face, `center` settles from the middle out, `ticker` each letter steps through the alphabet in order. */\n  variant?: ScrambleTextVariant\n  /** `hover` stays still until hovered or focused, for a link. Listens on the nearest link or button around it. */\n  effect?: ScrambleTextEffect\n  /** `view` plays once when first seen, `mount` plays right away. */\n  trigger?: ScrambleTextTrigger\n  /** The same seed gives the same noise. */\n  seed?: number\n}\n\nfunction ScrambleText({ text, as: Tag = \"span\", variant = \"letters\", effect, trigger = \"view\", seed = DEFAULT_SEED, className, ...props }: ScrambleTextProps) {\n  const reduceMotion = useReducedMotion() ?? false\n  const visualRef = useRef<HTMLSpanElement>(null)\n  const seen = useInView(visualRef, { once: true, margin: VIEW_MARGIN })\n  const { play, stop, isRunning } = useTimeline()\n  const { mode, order } = VARIANT_PLAN[variant]\n  const words = useMemo(() => scrambleWords(text, { mode, order, seed }), [text, mode, order, seed])\n  const auto = effect !== \"hover\" && !reduceMotion\n  const due = trigger === \"mount\" || seen\n\n  // Before paint, so the finished text from the first render never flashes ahead of the noise.\n  useLayoutEffect(() => {\n    if (auto && visualRef.current) createPainter(visualRef.current, words, mode, seed)(0)\n  }, [auto, words, mode, seed])\n\n  useEffect(() => {\n    if (!auto || !due || !visualRef.current) return\n    play(createPainter(visualRef.current, words, mode, seed))\n    return stop\n  }, [auto, due, words, mode, seed, play, stop])\n\n  useReplayHost(visualRef, effect === \"hover\" && !reduceMotion, () => {\n    if (visualRef.current && !isRunning()) play(createPainter(visualRef.current, words, mode, seed))\n  })\n\n  return (\n    <Tag {...props} data-slot=\"scramble-text\" data-variant={variant} data-effect={effect} className={cn(\"relative\", variant === \"mono\" && \"font-mono\", className)}>\n      {/* Noise read aloud letter by letter would be nonsense, so assistive tech gets the sentence once. */}\n      <span className=\"sr-only select-none\">{text}</span>\n      <span ref={visualRef} aria-hidden=\"true\" data-slot=\"scramble-text-visual\">\n        {/* The key drops a half-played frame. It sits inside the observed element, because useInView keeps watching the node it first got. */}\n        <Fragment key={`${variant}:${effect}:${seed}:${reduceMotion}:${text}`}>\n          {words.map((word, position) => (\n            <WordRow key={word.key} last={position === words.length - 1}>\n              {word.cells.map((cell) => (\n                <GlyphCell key={cell.index} target={cell.target} layerClassName={variant === \"mono\" ? \"text-muted-foreground\" : undefined} />\n              ))}\n            </WordRow>\n          ))}\n        </Fragment>\n      </span>\n    </Tag>\n  )\n}\n\nexport { ScrambleText, type ScrambleTextProps, type ScrambleTextVariant, type ScrambleTextEffect, type ScrambleTextTrigger }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/scramble-math.ts",
      "content": "import { pieceCount, pieceDelay, splitText, type SplitOrigin } from \"@/registry/miseui/ui/split-text-math\"\n\ntype GlyphKind = \"upper\" | \"lower\" | \"digit\" | \"hangul\" | \"fixed\"\ntype NoiseSet = \"letters\" | \"symbols\"\ntype ScrambleMode = NoiseSet | \"ticker\"\n\n/** Glyph churn is capped well under the frame rate: at 60 changes a second letters read as static, not as text settling. */\nconst NOISE_TICK_MS = 55\nconst TICKER_STEP_MS = 45\n/** How long the first letter stays noise, so even a one-letter text visibly settles. */\nconst LEAD_MS = 180\nconst MIN_TICKER_STEPS = 4\nconst TICKER_STEP_RANGE = 6\nconst HANGUL_START = 0xac00\nconst HANGUL_END = 0xd7a3\n/** One step of the medial vowel. Stepping one code point only swaps the final consonant, which barely reads as motion. */\nconst HANGUL_VOWEL_STEP = 28\n\nconst ALPHABET: Record<\"upper\" | \"lower\" | \"digit\", string> = {\n  upper: \"ABCDEFGHIJKLMNOPQRSTUVWXYZ\",\n  lower: \"abcdefghijklmnopqrstuvwxyz\",\n  digit: \"0123456789\",\n}\n\n/**\n * A wide glyph in the cell of a narrow letter runs over its neighbours, so narrow letters\n * only draw from narrow glyphs and the very widest (m, w) are left out of the regular pools.\n */\nconst NOISE_POOL = {\n  upper: \"ABCDEFGHKNOPRSUVXYZ\",\n  narrowUpper: \"IJLT\",\n  lower: \"abcdeghknopqsuvxyz\",\n  narrowLower: \"fijlrt\",\n  digit: ALPHABET.digit,\n  hangul: \"가나다라마바사아자차카타파하고노도로모보소오조호구누두루무부수우주후\",\n  symbols: \"#%&*+=/<>~^-_\",\n} as const\n\nconst NARROW_LETTERS = \"fijlrtIJLT\"\n\n/** Deterministic value in [0, 1). Same inputs, same glyph, so server and client agree and tests can pin a frame. */\nfunction seededUnit(seed: number, a: number, b = 0) {\n  const mixed = Math.imul((seed + 1) ^ Math.imul(a + 1, 0x9e3779b1) ^ Math.imul(b + 1, 0x85ebca77), 0xc2b2ae3d)\n  const shifted = Math.imul(mixed ^ (mixed >>> 15), 0x27d4eb2f)\n  return ((shifted ^ (shifted >>> 13)) >>> 0) / 4294967296\n}\n\nfunction glyphKind(glyph: string): GlyphKind {\n  if (/^[A-Z]$/.test(glyph)) return \"upper\"\n  if (/^[a-z]$/.test(glyph)) return \"lower\"\n  if (/^[0-9]$/.test(glyph)) return \"digit\"\n  if (/^[가-힣]$/.test(glyph)) return \"hangul\"\n  return \"fixed\"\n}\n\nfunction letterPool(target: string, kind: Exclude<GlyphKind, \"fixed\">) {\n  const narrow = NARROW_LETTERS.includes(target)\n  if (kind === \"upper\") return narrow ? NOISE_POOL.narrowUpper : NOISE_POOL.upper\n  if (kind === \"lower\") return narrow ? NOISE_POOL.narrowLower : NOISE_POOL.lower\n  return NOISE_POOL[kind]\n}\n\ninterface NoiseOptions {\n  set: NoiseSet\n  seed: number\n  index: number\n  tick: number\n}\n\n/** A stand-in for one letter. Punctuation and emoji are returned as they are: they carry no noise worth showing. */\nfunction noiseGlyph(target: string, { set, seed, index, tick }: NoiseOptions) {\n  const kind = glyphKind(target)\n  if (kind === \"fixed\") return target\n  const pool = set === \"symbols\" ? NOISE_POOL.symbols : letterPool(target, kind)\n  return pool[Math.floor(seededUnit(seed, index, tick) * pool.length)]\n}\n\n/** The glyph `stepsLeft` places before the target in its alphabet, wrapping round. */\nfunction tickerGlyph(target: string, stepsLeft: number) {\n  const kind = glyphKind(target)\n  if (kind === \"fixed\") return target\n  if (kind === \"hangul\") {\n    const code = target.charCodeAt(0) - HANGUL_VOWEL_STEP * stepsLeft\n    return String.fromCharCode(Math.min(HANGUL_END, Math.max(HANGUL_START, code)))\n  }\n  const alphabet = ALPHABET[kind]\n  const position = alphabet.indexOf(target) - stepsLeft\n  return alphabet[((position % alphabet.length) + alphabet.length) % alphabet.length]\n}\n\ninterface ScrambleCell {\n  target: string\n  index: number\n  /** When this cell begins stepping. Only the ticker waits. */\n  startAt: number\n  /** From this moment the cell shows its real letter. */\n  settleAt: number\n  /** Alphabet steps the ticker walks. */\n  steps: number\n}\n\ninterface ScrambleWord {\n  key: string\n  cells: readonly ScrambleCell[]\n}\n\ninterface ScrambleOptions {\n  mode: ScrambleMode\n  order: SplitOrigin\n  seed: number\n}\n\nfunction planCell(target: string, index: number, wave: number, { mode, seed }: ScrambleOptions): ScrambleCell {\n  if (glyphKind(target) === \"fixed\") return { target, index, startAt: 0, settleAt: 0, steps: 0 }\n  if (mode !== \"ticker\") return { target, index, startAt: 0, settleAt: LEAD_MS + wave, steps: 0 }\n  const steps = MIN_TICKER_STEPS + Math.floor(seededUnit(seed, index, -1) * TICKER_STEP_RANGE)\n  // Half the wave, because each letter also spends its own steps and the whole must stay short.\n  const startAt = Math.round(wave / 2)\n  return { target, index, startAt, settleAt: startAt + steps * TICKER_STEP_MS, steps }\n}\n\nfunction scrambleWords(text: string, options: ScrambleOptions): readonly ScrambleWord[] {\n  const words = splitText(text, \"chars\")\n  const count = pieceCount(words)\n  return words.map((word) => ({\n    key: word.key,\n    cells: word.pieces.map((piece) => planCell(piece.text, piece.index, pieceDelay(piece.index, count, \"chars\", options.order), options)),\n  }))\n}\n\n/** What a cell shows at a moment, or null once it shows its real letter. */\nfunction cellGlyph(cell: ScrambleCell, elapsed: number, mode: ScrambleMode, seed: number) {\n  if (elapsed >= cell.settleAt) return null\n  if (mode === \"ticker\") {\n    const stepsLeft = elapsed < cell.startAt ? cell.steps : Math.ceil((cell.settleAt - elapsed) / TICKER_STEP_MS)\n    return tickerGlyph(cell.target, stepsLeft)\n  }\n  return noiseGlyph(cell.target, { set: mode, seed, index: cell.index, tick: Math.floor(Math.max(0, elapsed) / NOISE_TICK_MS) })\n}\n\nfunction scrambleDuration(words: readonly ScrambleWord[]) {\n  return words.reduce((longest, word) => word.cells.reduce((most, cell) => Math.max(most, cell.settleAt), longest), 0)\n}\n\nexport {\n  cellGlyph,\n  glyphKind,\n  noiseGlyph,\n  scrambleDuration,\n  scrambleWords,\n  seededUnit,\n  tickerGlyph,\n  type GlyphKind,\n  type NoiseSet,\n  type ScrambleCell,\n  type ScrambleMode,\n  type ScrambleOptions,\n  type ScrambleWord,\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/scramble-play.tsx",
      "content": "import type * as React from \"react\"\nimport { useCallback, useEffect, useRef } from \"react\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\n/** Draws one moment and says whether the run is over. */\ntype FramePainter = (elapsedMs: number) => boolean\n\n/** One requestAnimationFrame per component. Painting goes straight to the DOM, so nothing re-renders per frame. */\nfunction useTimeline() {\n  const frame = useRef(0)\n  const stop = useCallback(() => {\n    cancelAnimationFrame(frame.current)\n    frame.current = 0\n  }, [])\n  const play = useCallback(\n    (paint: FramePainter) => {\n      stop()\n      let origin: number | null = null\n      const tick = (now: number) => {\n        origin = origin ?? now\n        frame.current = paint(now - origin) ? 0 : requestAnimationFrame(tick)\n      }\n      frame.current = requestAnimationFrame(tick)\n    },\n    [stop],\n  )\n  const isRunning = useCallback(() => frame.current !== 0, [])\n  useEffect(() => stop, [stop])\n  return { play, stop, isRunning }\n}\n\nconst REPLAY_HOST = \"a, button, [role='link'], [role='button']\"\n\n/**\n * Text inside a link cannot take focus itself, and the link is usually bigger than the text.\n * So the hover and focus that replay it are listened for on the nearest control around it.\n */\nfunction useReplayHost(anchor: React.RefObject<HTMLElement | null>, enabled: boolean, replay: () => void) {\n  const latest = useRef(replay)\n  useEffect(() => {\n    latest.current = replay\n  })\n  useEffect(() => {\n    const element = anchor.current\n    if (!enabled || !element) return\n    const host = element.closest<HTMLElement>(REPLAY_HOST) ?? element.parentElement ?? element\n    const onEnter = () => latest.current()\n    // A pointer that clicked already played it on the way in. Only keyboard focus is news.\n    const onFocus = (event: FocusEvent) => {\n      if (event.target instanceof Element && event.target.matches(\":focus-visible\")) latest.current()\n    }\n    host.addEventListener(\"mouseenter\", onEnter)\n    host.addEventListener(\"focusin\", onFocus)\n    return () => {\n      host.removeEventListener(\"mouseenter\", onEnter)\n      host.removeEventListener(\"focusin\", onFocus)\n    }\n  }, [anchor, enabled])\n}\n\ninterface GlyphCellProps {\n  target: string\n  /** Shown instead of the letter from the very first render, for text that starts hidden. */\n  before?: string\n  layerClassName?: string\n  children?: React.ReactNode\n}\n\n/**\n * The real letter sizes the cell, so a proportional font never jitters and lines never reflow.\n * The cell stays inline: an inline-block would stop a link's underline and break kerning at every letter.\n * The stand-in lies over it and is centred by flex, because text-center lets a wider glyph spill only to the right.\n * An inline box is as tall as the font rather than the line, so the stand-in needs a normal line height to share its baseline.\n * At rest the layer is empty, which leaves plain letters that select and copy normally.\n */\nfunction GlyphCell({ target, before, layerClassName, children }: GlyphCellProps) {\n  return (\n    <span data-slot=\"glyph-cell\" className=\"relative\">\n      <span data-slot=\"glyph-letter\" className={before === undefined ? undefined : \"invisible\"}>\n        {target}\n      </span>\n      <span data-slot=\"glyph-layer\" className={cn(\"pointer-events-none absolute inset-0 flex justify-center leading-[normal] select-none\", layerClassName)}>\n        {before}\n      </span>\n      {children}\n    </span>\n  )\n}\n\nfunction glyphCells(visual: HTMLElement) {\n  return Array.from(visual.querySelectorAll<HTMLElement>(\"[data-slot='glyph-cell']\"))\n}\n\n/**\n * Null puts the real letter back. Writes only on change: most cells are idle most frames.\n * Hidden by colour, not visibility, so an underline from a link around it keeps drawing under the noise.\n */\nfunction paintGlyph(cell: HTMLElement, glyph: string | null) {\n  const letter = cell.children[0] as HTMLElement\n  const layer = cell.children[1] as HTMLElement\n  const next = glyph ?? \"\"\n  if (layer.textContent !== next) layer.textContent = next\n  const color = glyph === null ? \"\" : \"transparent\"\n  if (letter.style.color !== color) letter.style.color = color\n}\n\ninterface WordRowProps {\n  last: boolean\n  children: React.ReactNode\n}\n\n/** A word holds together, so a line only ever breaks at a real space. */\nfunction WordRow({ last, children }: WordRowProps) {\n  return (\n    <span>\n      <span data-slot=\"glyph-word\" className=\"whitespace-nowrap\">\n        {children}\n      </span>\n      {last ? null : \" \"}\n    </span>\n  )\n}\n\nexport { GlyphCell, WordRow, glyphCells, paintGlyph, useReplayHost, useTimeline, type FramePainter }\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "type": "registry:ui"
}