{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scramble-text",
  "title": "Scramble text",
  "description": "A wordmark that scrambles into place a letter at a time, flashes a decoy word that is struck out, then rises in for good.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/items/scramble-text/scramble-text.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion, useReducedMotionConfig } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface ScrambleTextProps\n  extends Omit<React.HTMLAttributes<HTMLSpanElement>, \"children\"> {\n  /** The word the scramble lands on. */\n  text: string;\n  /**\n   * The frames shown on the way, one every `frameDuration`. Defaults to the\n   * original Steeze frames for \"Steeze\", or a seeded reveal for other text.\n   */\n  frames?: string[];\n  /**\n   * A word shown after the scramble, struck through, then swapped for the\n   * text. Defaults to \"xo\" repeated to the text's length (\"xoxoxo\" for six\n   * letters). Pass `null` or \"\" to land on the text straight away.\n   */\n  decoy?: string | null;\n  /** Milliseconds per scramble frame. */\n  frameDuration?: number;\n  /** Plays the sequence. False shows the text at rest; turning it back on replays. */\n  play?: boolean;\n  /** Called once the text has landed. */\n  onComplete?: () => void;\n}\n\n/** After the decoy appears, how long until it is struck, then how long it stays struck. */\nconst DECOY_PAUSE = 500;\nconst STRIKE_HOLD = 1000;\nconst SWAP = { duration: 0.4 };\nconst STEEZE_FRAMES = [\"Sruxay\", \"Stqorc\", \"Stewxd\", \"Steekj\", \"Steezo\", \"Steeze\"];\nconst LETTERS = \"abcdefghijklmnopqrstuvwxyz\";\n\nfunction subscribeToHydration() {\n  return () => {};\n}\n\n/**\n * The text revealed left to right over random letters. Seeded from the text,\n * so the server and the browser draw the same frames.\n */\nfunction revealFrames(text: string): string[] {\n  const chars = [...text];\n  let seed = 2166136261;\n  for (const char of chars) seed = Math.imul(seed ^ char.charCodeAt(0), 16777619) >>> 0;\n  const random = () => {\n    seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;\n    return seed / 2 ** 32;\n  };\n  return chars.map((_, frame) =>\n    chars\n      .map((char, i) => (i <= frame || char.trim() === \"\" ? char : LETTERS[Math.floor(random() * LETTERS.length)]))\n      .join(\"\"),\n  );\n}\n\n/**\n * Steeze's splash wordmark: the name scrambles into place a letter at a time,\n * a decoy word appears and is struck out, then the name rises in for good.\n *\n * It inherits font, size, tracking and colour from where it sits, so give it a\n * display face. The struck decoy fades to `scramble-strike`. Screen readers hear only\n * the text, and reduced motion shows the text at rest.\n */\nexport function ScrambleText({\n  text,\n  frames,\n  decoy,\n  frameDuration = 100,\n  play = true,\n  onComplete,\n  className,\n  ...props\n}: ScrambleTextProps) {\n  const reduceMotion = useReducedMotionConfig();\n  // Motion's default config is \"never\"; honour the device preference as well.\n  const prefersReducedMotion = useReducedMotion();\n  const hydrated = React.useSyncExternalStore(subscribeToHydration, () => true, () => false);\n  const sequence = React.useMemo(\n    () => frames?.length ? frames : text === \"Steeze\" ? STEEZE_FRAMES : revealFrames(text),\n    [frames, text],\n  );\n  const decoyText = decoy === undefined ? \"xo\".repeat(Math.ceil(text.length / 2)).slice(0, text.length) : decoy;\n  const animate = play && !(hydrated && (reduceMotion || prefersReducedMotion));\n\n  // Steps: one per frame, then the decoy, then the strike, then done.\n  const decoyStep = sequence.length;\n  const strikeStep = decoyStep + 1;\n  const doneStep = decoyText ? strikeStep + 1 : decoyStep;\n\n  // A new run whenever what is being played changes; its step starts at 0\n  // without a reset effect, because a stale step belongs to the old run.\n  const run = React.useMemo(\n    () => ({ sequence, decoyText, frameDuration, animate }),\n    [sequence, decoyText, frameDuration, animate],\n  );\n  const [progress, setProgress] = React.useState({ run, step: 0 });\n  const step = !animate ? doneStep : progress.run === run ? progress.step : 0;\n\n  const completeRef = React.useRef(onComplete);\n  React.useEffect(() => {\n    completeRef.current = onComplete;\n  });\n\n  React.useEffect(() => {\n    if (!run.animate) return;\n    // The source repeats the first frame on its first interval tick.\n    const at: number[] = sequence.slice(1).map((_, i) => (i + 2) * frameDuration);\n    const decoyAt = (sequence.length + 1) * frameDuration;\n    if (decoyText) at.push(decoyAt, decoyAt + DECOY_PAUSE);\n    const doneAt = decoyAt + (decoyText ? DECOY_PAUSE + STRIKE_HOLD : 0);\n    at.push(doneAt);\n    const timers = at.map((ms, i) =>\n      window.setTimeout(() => {\n        setProgress({ run, step: i + 1 });\n      }, ms),\n    );\n    timers.push(window.setTimeout(() => completeRef.current?.(), doneAt + SWAP.duration * 1000));\n    return () => timers.forEach(window.clearTimeout);\n  }, [run, sequence, decoyText, frameDuration, decoyStep, doneStep]);\n\n  const phase = step < strikeStep && step < doneStep ? \"scramble\" : step < doneStep ? \"strike\" : \"done\";\n\n  return (\n    <span className={cn(\"relative inline-grid justify-items-center align-baseline leading-[1.5]\", className)} {...props}>\n      <span className=\"sr-only\">{text}</span>\n      {/* Reserve every frame's width so every phase keeps the same centre. */}\n      <span aria-hidden=\"true\" className=\"invisible col-start-1 row-start-1 inline-grid justify-items-center\">\n        {[...new Set([...sequence, decoyText, text])].map((word) => (\n          <span key={word} className=\"col-start-1 row-start-1 whitespace-pre\">{word}</span>\n        ))}\n      </span>\n      <AnimatePresence mode=\"popLayout\">\n        {phase !== \"done\" ? (\n          <span key=\"intro\" aria-hidden=\"true\" className=\"absolute inset-0 flex items-center justify-center tracking-[-0.02em]\">\n            {phase === \"scramble\" ? (\n              <span className=\"whitespace-pre\">{step < decoyStep ? sequence[step] : decoyText}</span>\n            ) : (\n              <motion.span\n                initial={{ color: \"var(--ink)\" }}\n                animate={{ color: \"var(--scramble-strike)\" }}\n                exit={{ y: \"-100%\", opacity: 0 }}\n                transition={SWAP}\n                className=\"relative inline-block whitespace-pre\"\n              >\n                {decoyText}\n                <motion.span\n                  className=\"absolute inset-x-0 top-[60%] h-0.5 origin-center bg-scramble-strike\"\n                  initial={{ scaleX: 0 }}\n                  animate={{ scaleX: 1 }}\n                  transition={{ duration: 0.2 }}\n                />\n              </motion.span>\n            )}\n          </span>\n        ) : (\n          <motion.span\n            key=\"done\"\n            aria-hidden=\"true\"\n            // The source moves 10% of its full-height splash, not of a text line.\n            initial={animate ? { y: \"10vh\", opacity: 0 } : false}\n            animate={{ y: \"0vh\", opacity: 1 }}\n            transition={SWAP}\n            className=\"absolute inset-0 flex items-center justify-center whitespace-pre tracking-[-0.05em]\"\n          >\n            {text}\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </span>\n  );\n}\n\nexport default ScrambleText;\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "kind": "primitive",
    "whenToUse": "Use once, for a brand moment: a splash screen, the first sight of a wordmark, a reveal on a landing page. Allow room for its 10vh entrance. At the default pace it settles after 2.6 seconds.",
    "origin": {
      "project": "Steeze",
      "path": "src/App.jsx (splash: splashReducer, scrambleWords and the scramble/strike/after phases)"
    },
    "credits": []
  },
  "type": "registry:ui"
}
