{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"typewriter","type":"registry:component","title":"Typewriter","description":"Types, pauses and rewrites the last word with a human rhythm.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":[],"registryDependencies":["utils"],"files":[{"path":"components/typewriter.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n// Per-character typing delay: a base plus up to this much jitter, which lands\n// around 8 to 14 keys a second, the pace of someone who knows the word.\nconst TYPE_BASE = 62;\nconst TYPE_JITTER = 64;\n// Long enough to read the finished sentence once.\nconst HOLD = 1800;\n// How long the word sits selected before the first key replaces it: the\n// beat where a person decides on the new word. Includes the 220ms sweep.\nconst SELECTED_FOR = 620;\n// Reduced motion swaps whole words on this interval instead of typing.\nconst SWAP_EVERY = 2800;\n\n// A macOS-length blink with short fades, rather than a hard on/off flash.\n// The caret goes solid while keys move and disappears while a selection is\n// up, as it does in a real text field.\n// The selection sweeps in from the word's end (shift + option + left) in\n// 220ms, but vanishes the instant a key replaces it: an editor never fades\n// a selection out.\n// Each new letter lands with a 140ms ink-in (opacity and a 2px blur) so a\n// keystroke reads as struck rather than popped in.\nconst CSS = `\n@keyframes typewriter-caret {\n  0%, 45% { opacity: 1; }\n  55%, 95% { opacity: 0; }\n  100% { opacity: 1; }\n}\n.typewriter-caret { animation: typewriter-caret 1.06s linear infinite; }\n[data-typing=\"true\"] .typewriter-caret { animation: none; }\n[data-selecting=\"true\"] .typewriter-caret { visibility: hidden; }\n.typewriter-selection {\n  scale: 0 1;\n  transform-origin: right;\n  transition: scale 220ms cubic-bezier(0.23, 1, 0.32, 1);\n}\n[data-selecting=\"false\"] .typewriter-selection { transition: none; }\n[data-selecting=\"true\"] .typewriter-selection { scale: 1 1; }\n@keyframes typewriter-ink {\n  from { opacity: 0; filter: blur(2px); }\n}\n.typewriter-letter { animation: typewriter-ink 140ms cubic-bezier(0.23, 1, 0.32, 1); }\n@media (prefers-reduced-motion: reduce) {\n  .typewriter-selection { transition: none; }\n  .typewriter-letter { animation: none; }\n}\n`;\n\n// Deterministic, and smooth from key to key: a slow wave carries a rhythm\n// through the word and a small hash roughens it, so delays vary the way a\n// hand does instead of jumping between extremes.\nfunction jitter(word: number, char: number) {\n  const wave = Math.sin(char * 1.9 + word * 2.7) * 0.5 + 0.5;\n  const n = Math.sin(char * 12.9898 + word * 78.233) * 43758.5453;\n  return wave * 0.7 + (n - Math.floor(n)) * 0.3;\n}\n\nexport function Typewriter({\n  prefix,\n  words,\n  className,\n}: {\n  prefix: string;\n  words: string[];\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const rootRef = useRef<HTMLSpanElement>(null);\n  const textRef = useRef<HTMLSpanElement>(null);\n  const [swapIndex, setSwapIndex] = useState(0);\n\n  // Typing writes straight to the DOM, so a keystroke never re-renders React.\n  useEffect(() => {\n    if (reduceMotion) return;\n    const root = rootRef.current;\n    const text = textRef.current;\n    if (!root || !text || words.length === 0) return;\n\n    let word = 0;\n    let length = words[0].length;\n    let timer: ReturnType<typeof setTimeout>;\n\n    const strike = (char: string) => {\n      const letter = document.createElement(\"span\");\n      letter.className = \"typewriter-letter\";\n      letter.textContent = char;\n      text.append(letter);\n    };\n\n    const type = () => {\n      const target = words[word];\n      if (length < target.length) {\n        strike(target[length]);\n        length++;\n      }\n      const finished = length === target.length;\n      // Solid while keys are moving, blinking while it waits.\n      root.dataset.typing = String(!finished);\n      timer = finished\n        ? setTimeout(select, HOLD)\n        : setTimeout(type, TYPE_BASE + jitter(word, length) * TYPE_JITTER);\n    };\n\n    // Rewrites the way people do: select the word, then type over it. The\n    // first key replaces the whole selection at once.\n    const select = () => {\n      if (words.length < 2) return;\n      root.dataset.selecting = \"true\";\n      timer = setTimeout(() => {\n        word = (word + 1) % words.length;\n        length = 0;\n        text.textContent = \"\";\n        root.dataset.selecting = \"false\";\n        type();\n      }, SELECTED_FOR);\n    };\n\n    // Starts on the finished first word, which is also what the server\n    // rendered, so nothing flashes empty before hydration.\n    root.dataset.typing = \"false\";\n    timer = setTimeout(select, HOLD);\n    return () => {\n      clearTimeout(timer);\n      // Leaves the DOM as React rendered it, so a words change or remount\n      // starts clean instead of from a half typed, selected word.\n      text.textContent = words[0] ?? \"\";\n      root.dataset.selecting = \"false\";\n    };\n  }, [reduceMotion, words]);\n\n  useEffect(() => {\n    if (!reduceMotion || words.length < 2) return;\n    const id = setInterval(\n      () => setSwapIndex((i) => (i + 1) % words.length),\n      SWAP_EVERY,\n    );\n    return () => clearInterval(id);\n  }, [reduceMotion, words.length]);\n\n  const longest = words.reduce((a, b) => (b.length > a.length ? b : a), \"\");\n  const sentence = `${prefix} ${new Intl.ListFormat(\"en\", {\n    type: \"disjunction\",\n  }).format(words)}.`;\n\n  return (\n    <span className={cn(\"inline-grid whitespace-pre\", className)}>\n      <style href=\"typewriter\" precedence=\"default\">\n        {CSS}\n      </style>\n      <span className=\"sr-only\">{sentence}</span>\n      {/* Reserves the widest state, so the line is laid out once and the\n          prefix never moves as the word grows to the right. */}\n      <span aria-hidden className=\"invisible col-start-1 row-start-1\">\n        {prefix} {longest}\n        <Caret />\n      </span>\n      <span\n        ref={rootRef}\n        aria-hidden\n        data-typing=\"false\"\n        data-selecting=\"false\"\n        className=\"col-start-1 row-start-1 text-left\"\n      >\n        {prefix}{\" \"}\n        {reduceMotion ? (\n          <span className=\"inline-grid\">\n            {words.map((w, i) => (\n              <span\n                key={w}\n                // Slower than UI feedback on purpose: nothing was clicked,\n                // so a gentle crossfade reads as ambient, not as a response.\n                className={cn(\n                  \"col-start-1 row-start-1 transition-[opacity] duration-500 ease-in-out\",\n                  i !== swapIndex && \"opacity-0\",\n                )}\n              >\n                {w}\n              </span>\n            ))}\n          </span>\n        ) : (\n          <>\n            <span className=\"relative inline-block\">\n              {/* A text selection highlight: the ink stays, a tinted block\n                  sits behind it. Slightly taller than the glyphs, like the\n                  line box a browser paints. */}\n              <span className=\"typewriter-selection absolute -inset-x-px -inset-y-[0.06em] rounded-[3px] bg-foreground/15 dark:bg-foreground/25\" />\n              <span ref={textRef} className=\"relative\">\n                {words[0]}\n              </span>\n            </span>\n            <Caret />\n          </>\n        )}\n      </span>\n    </span>\n  );\n}\n\nfunction Caret() {\n  return (\n    <span className=\"typewriter-caret ml-0.5 inline-block h-[1.1em] w-[2px] rounded-full bg-foreground align-[-0.18em]\" />\n  );\n}\n\nconst WORDS = [\"right\", \"fast\", \"alive\", \"effortless\"];\n\nexport default function TypewriterDemo() {\n  return (\n    <p className=\"text-xl font-medium tracking-tight text-foreground\">\n      <Typewriter prefix=\"Build interfaces that feel\" words={WORDS} />\n    </p>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"categories":["text"],"docs":"From ui lab: https://lab.xevrion.dev/lab/typewriter"}