{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"typewriter-text","type":"registry:component","title":"Typewriter Text","description":"A typewriter with a human hand: jittered keystrokes, beats at punctuation, a caret that rests only when it waits, accelerating backspace, a reserved width so nothing shifts, and an optional syntax-tinted code mode.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/typewriter-text.tsx","content":"\"use client\";\n\n/*\n * TypewriterText: types, holds, backspaces and cycles through phrases.\n *\n * Cadence is human: every character gets a jittered delay, commas and full\n * stops add a beat, the caret holds still while keys are going down and blinks\n * only when it waits, and backspacing starts with a short beat then accelerates.\n * The box reserves the width of the longest phrase (all phrases are stacked as\n * invisible ghosts in one grid cell), so nothing around it ever shifts. Pauses\n * while off screen, while the tab is hidden and while `paused` is set. The\n * animated text is aria-hidden; the full phrases are read from a sr-only span.\n * Reduced motion swaps typing for a calm cross-fade between whole phrases.\n */\n\nimport { useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from \"react\";\n\nconst DEFAULT_PHRASES = [\"Build fast.\", \"Ship faster.\", \"Vibe code.\"];\nconst COLOR = /^(?:#(?:[0-9a-f]{3}|[0-9a-f]{6})|currentColor)$/i;\nconst RM = \"(prefers-reduced-motion: reduce)\";\n\nconst CSS = [\n  \".twx-caret{animation:none}\",\n  '.twx-caret[data-s=\"hold\"],.twx-caret[data-s=\"gap\"],.twx-caret[data-s=\"idle\"]{animation:twx-blink 1.1s infinite}',\n  \"@keyframes twx-blink{0%,49%{opacity:1}50%,100%{opacity:0}}\",\n  \".twx-fade{animation:twx-in 520ms cubic-bezier(0.2,0.7,0.2,1) both}\",\n  \"@keyframes twx-in{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:blur(0)}}\",\n  \"@media (prefers-reduced-motion:reduce){.twx-caret{animation:none!important}}\",\n].join(\"\\n\");\n\ntype Mode = \"type\" | \"hold\" | \"erase\" | \"gap\";\ntype CaretShape = \"bar\" | \"block\" | \"underscore\";\n\ninterface View {\n  i: number;\n  n: number;\n  mode: Mode;\n}\n\nfunction subscribeRM(cb: () => void) {\n  const m = window.matchMedia(RM);\n  m.addEventListener(\"change\", cb);\n  return () => m.removeEventListener(\"change\", cb);\n}\n\n/** Split into user-perceived characters so backspacing never cuts an emoji in half. */\nfunction split(s: string): string[] {\n  const Seg = (Intl as unknown as { Segmenter?: new (l?: string, o?: { granularity: string }) => { segment(x: string): Iterable<{ segment: string }> } }).Segmenter;\n  if (Seg) return Array.from(new Seg(undefined, { granularity: \"grapheme\" }).segment(s), (x) => x.segment);\n  return Array.from(s);\n}\n\nconst TOKEN =\n  /(\\/\\/.*$|#.*$)|(\"[^\"]*\"?|'[^']*'?|`[^`]*`?)|(\\b\\d+(?:\\.\\d+)?\\b)|(\\b(?:const|let|var|function|return|import|from|export|default|await|async|if|else|new|type|interface|npm|run|git|push)\\b)|([A-Za-z_$][\\w$]*(?=\\())|([{}()[\\];,.:=<>+\\-*/!&|?]+)/g;\nconst TINT = [\"\", \"#7d8590\", \"#22b07d\", \"#e0914a\", \"#a78bfa\", \"#5b9cf5\", \"#8b949e\"];\n\n/** A very small tokenizer: enough to tint keywords, strings, numbers, calls and punctuation as they appear. */\nfunction tint(text: string): ReactNode[] {\n  const out: ReactNode[] = [];\n  let last = 0;\n  let k = 0;\n  for (const m of text.matchAll(TOKEN)) {\n    const at = m.index ?? 0;\n    if (at > last) out.push(text.slice(last, at));\n    const g = m.findIndex((v, idx) => idx > 0 && v !== undefined);\n    out.push(\n      <span key={k++} style={{ color: TINT[g] }}>\n        {m[0]}\n      </span>,\n    );\n    last = at + m[0].length;\n  }\n  if (last < text.length) out.push(text.slice(last));\n  return out;\n}\n\nexport function TypewriterText({\n  phrases = DEFAULT_PHRASES,\n  typeSpeed = 60,\n  pauseMs = 1400,\n  eraseSpeed,\n  loop = true,\n  code = false,\n  caret = \"bar\",\n  caretColor = \"#ef4444\",\n  paused = false,\n  onCycle,\n  className = \"text-3xl font-black tracking-tight\",\n}: {\n  /** Phrases to type, in order. */\n  phrases?: string[];\n  /** Base delay per typed character, in ms. Each key is jittered around it. */\n  typeSpeed?: number;\n  /** How long a finished phrase is held before it is erased, in ms. */\n  pauseMs?: number;\n  /** Base delay per erased character. Defaults to typeSpeed / 2. */\n  eraseSpeed?: number;\n  /** false types the list once and stops on the last phrase. */\n  loop?: boolean;\n  /** Monospace and tint keywords, strings, numbers and calls as they are typed. */\n  code?: boolean;\n  caret?: CaretShape;\n  /** Hex colour or currentColor. */\n  caretColor?: string;\n  /** Freezes the animation in place. */\n  paused?: boolean;\n  /** Called with the phrase index each time a phrase finishes typing. */\n  onCycle?: (index: number) => void;\n  /** Replaces the default look \"text-3xl font-black tracking-tight\". */\n  className?: string;\n}) {\n  const list = phrases.length ? phrases : DEFAULT_PHRASES;\n  const key = list.join(\"\\u0001\");\n  const reduced = useSyncExternalStore(subscribeRM, () => window.matchMedia(RM).matches, () => false);\n  const [view, setView] = useState<View>({ i: 0, n: 0, mode: \"type\" });\n  const [away, setAway] = useState(false);\n  const root = useRef<HTMLSpanElement>(null);\n  const live = useRef({ list, typeSpeed, pauseMs, eraseSpeed, loop, code, paused, onCycle, onscreen: true });\n  const wake = useRef<(() => void) | null>(null);\n\n  useEffect(() => {\n    live.current = { list, typeSpeed, pauseMs, eraseSpeed, loop, code, paused, onCycle, onscreen: live.current.onscreen };\n    wake.current?.();\n  });\n\n  useEffect(() => {\n    const el = root.current;\n    let timer: number | undefined;\n    let i = 0;\n    let n = 0;\n    let mode: Mode = \"type\";\n    let run = 0;\n    let finished = false;\n\n    const can = () => live.current.onscreen && !live.current.paused && document.visibilityState === \"visible\";\n\n    if (reduced) {\n      const show = () => setView({ i, n: split(live.current.list[i % live.current.list.length] ?? \"\").length, mode: \"hold\" });\n      timer = window.setTimeout(function again() {\n        show();\n        if (!can()) {\n          timer = window.setTimeout(again, 500);\n          return;\n        }\n        const len = live.current.list.length;\n        live.current.onCycle?.(i % len);\n        i = (i + 1) % len;\n        timer = len > 1 ? window.setTimeout(again, Math.max(live.current.pauseMs + 900, 2400)) : undefined;\n      }, 0);\n      return () => window.clearTimeout(timer);\n    }\n\n    const tick = () => {\n      timer = undefined;\n      if (finished || !can()) return;\n      const c = live.current;\n      const len = c.list.length;\n      const segs = split(c.list[i % len] ?? \"\");\n      const base = Math.max(c.typeSpeed, 5);\n      let delay = base;\n      if (mode === \"type\") {\n        n = Math.min(n + 1, segs.length);\n        const ch = segs[n - 1] ?? \"\";\n        if (n >= segs.length) {\n          mode = \"hold\";\n          delay = c.pauseMs;\n          c.onCycle?.(i % len);\n        } else {\n          delay = base * (0.55 + Math.random() * 0.9);\n          if (ch === \" \") delay *= 1.15;\n          if (\",;:\".includes(ch)) delay += base * 4;\n          else if (\".!?…—\".includes(ch)) delay += base * 7;\n          else if (c.code && \"{(\".includes(ch)) delay += base * 2;\n        }\n      } else if (mode === \"hold\") {\n        if (!c.loop && i >= len - 1) {\n          finished = true;\n          return;\n        }\n        mode = \"erase\";\n        run = 0;\n        delay = 170;\n      } else if (mode === \"erase\") {\n        n = Math.max(0, n - 1);\n        run += 1;\n        const e = Math.max(c.eraseSpeed ?? base / 2, 5);\n        if (n === 0) {\n          mode = \"gap\";\n          delay = 300;\n        } else delay = Math.max(12, e * Math.max(0.35, 1 - run * 0.05));\n      } else {\n        i = (i + 1) % len;\n        mode = \"type\";\n        delay = base * 2;\n      }\n      setView({ i, n, mode });\n      timer = window.setTimeout(tick, delay);\n    };\n\n    wake.current = () => {\n      if (timer === undefined && !finished && can()) timer = window.setTimeout(tick, 80);\n    };\n    timer = window.setTimeout(tick, 380);\n\n    const io = el\n      ? new IntersectionObserver(\n          ([e]) => {\n            live.current.onscreen = !!e?.isIntersecting;\n            setAway(!live.current.onscreen);\n            wake.current?.();\n          },\n          { rootMargin: \"100px\" },\n        )\n      : null;\n    if (el) io?.observe(el);\n    const vis = () => wake.current?.();\n    document.addEventListener(\"visibilitychange\", vis);\n    return () => {\n      window.clearTimeout(timer);\n      io?.disconnect();\n      document.removeEventListener(\"visibilitychange\", vis);\n      wake.current = null;\n    };\n    // key restarts the cycle when the phrase list changes; everything else is read live.\n  }, [key, reduced]);\n\n  const phrase = list[view.i % list.length] ?? \"\";\n  const shown = split(phrase).slice(0, view.n).join(\"\");\n  const state = reduced ? \"idle\" : paused || away ? \"idle\" : view.mode === \"type\" || view.mode === \"erase\" ? \"type\" : view.mode;\n  const color = COLOR.test(caretColor) ? caretColor : \"#ef4444\";\n  const shape: CaretShape = caret === \"block\" || caret === \"underscore\" ? caret : \"bar\";\n  const caretBox =\n    shape === \"bar\"\n      ? { width: \"0.08em\", minWidth: 2, height: \"1em\", verticalAlign: \"-0.12em\", marginLeft: \"0.06em\" }\n      : shape === \"block\"\n        ? { width: \"0.55em\", height: \"1.02em\", verticalAlign: \"-0.14em\", marginLeft: \"0.06em\", opacity: 0.85 }\n        : { width: \"0.6em\", height: \"0.12em\", verticalAlign: \"baseline\", marginLeft: \"0.06em\" };\n\n  return (\n    <>\n      <style>{CSS}</style>\n      <span\n        ref={root}\n        className={\"relative inline-grid max-w-full align-baseline \" + className}\n        style={code ? { fontFamily: \"ui-monospace, SFMono-Regular, Menlo, Consolas, monospace\" } : undefined}\n      >\n        <span className=\"sr-only\">{list.join(\" \")}</span>\n        <span aria-hidden=\"true\" className=\"contents\">\n          {list.map((p, idx) => (\n            <span key={idx} className=\"invisible col-start-1 row-start-1 break-words whitespace-pre-wrap\">\n              {p}\n              <span className=\"inline-block\" style={{ ...caretBox, background: \"transparent\" }} />\n            </span>\n          ))}\n          <span key={reduced ? view.i : \"live\"} className={\"col-start-1 row-start-1 break-words whitespace-pre-wrap\" + (reduced ? \" twx-fade\" : \"\")}>\n            {code ? tint(shown) : shown}\n            <span\n              className=\"twx-caret inline-block\"\n              data-s={state}\n              style={{ ...caretBox, background: color, borderRadius: shape === \"bar\" ? 1 : 0 }}\n            />\n          </span>\n        </span>\n      </span>\n    </>\n  );\n}\n","type":"registry:component","target":"components/viberdy/typewriter-text.tsx"}],"meta":{"tier":"free","tags":["typewriter","text","cycle","caret","typing","headline","code","prompt","stable-width"],"href":"https://viberdy.dev/library/typewriter-text","aiPrompt":"Create a React + Tailwind \"typewriter\" component named TypewriterText. One self-contained \"use client\" file, React only (useEffect, useRef, useState, useSyncExternalStore) - no animation library.\n\nProps (all optional): phrases (string[], default [\"Build fast.\", \"Ship faster.\", \"Vibe code.\"]; hoist the default to a module constant); typeSpeed (ms per char, default 60); pauseMs (hold on a finished phrase, default 1400); eraseSpeed (ms per erased char, default typeSpeed / 2); loop (default true; false types the list once and stops on the last phrase); code (default false: monospace + syntax tint); caret (\"bar\" | \"block\" | \"underscore\", default \"bar\"); caretColor (hex or currentColor, validate with /^(?:#(?:[0-9a-f]{3}|[0-9a-f]{6})|currentColor)$/i, default \"#ef4444\"); paused (default false); onCycle(index) (called when a phrase finishes typing); className (replaces the default look \"text-3xl font-black tracking-tight\"). Text colour is inherited, so it works on light and dark pages.\n\nEngine: ONE mount effect keyed on phrases.join(\"\\u0001\") and the reduced-motion flag, running a self-rescheduling setTimeout (never a setInterval or rAF). Local variables i (phrase), n (characters shown), mode (\"type\" | \"hold\" | \"erase\" | \"gap\"); each tick computes the next step and calls setView({i, n, mode}). Everything else (typeSpeed, pauseMs, eraseSpeed, loop, code, paused, onCycle, onscreen) is read from a ref synced in an effect, so changing a prop never restarts the cycle. Split phrases into graphemes (Intl.Segmenter if present, else Array.from) so erasing never cuts an emoji. First tick after 380ms.\n- type: n+1. Delay = typeSpeed * (0.55 + random*0.9); space x1.15; after , ; : add 4*typeSpeed; after . ! ? ... or an em dash add 7*typeSpeed; in code mode after { or ( add 2*typeSpeed. When n reaches the end: mode hold, delay pauseMs, call onCycle.\n- hold: if !loop and last phrase, finish (stop). Else mode erase, delay 170ms (a beat before backspacing).\n- erase: n-1; delay = max(12, eraseSpeed * max(0.35, 1 - 0.05 * count)) so backspace accelerates; at n = 0 go to gap with delay 300.\n- gap: next phrase (wrap), mode type, delay 2*typeSpeed.\nThe tick returns without rescheduling when it cannot run: not on screen (IntersectionObserver, rootMargin 100px), paused, or document.visibilityState hidden. A wake() function (called from the observer, a visibilitychange listener and after every render) reschedules in 80ms if no timer is pending and the loop has not finished.\n\nStructure: a style tag (static CSS string, no prop interpolation) then a root span \"relative inline-grid max-w-full align-baseline\" + className (monospace stack in code mode). Inside: a sr-only span with phrases.join(\" \"), then an aria-hidden \"contents\" span holding one invisible ghost per phrase (col-start-1 row-start-1, invisible, whitespace-pre-wrap, break-words, with an invisible caret-sized box appended) plus the live span in the same grid cell. The grid cell therefore takes the width and height of the longest phrase. The live span shows the typed text (code mode: tokenized and tinted by a regex: comments #7d8590, strings #22b07d, numbers #e0914a, keywords #a78bfa, calls #5b9cf5, punctuation #8b949e) then the caret.\n\nCaret: inline-block, background caretColor. bar: width 0.08em (min 2px), height 1em, vertical-align -0.12em, radius 1px. block: 0.55em x 1.02em, opacity 0.85. underscore: 0.6em x 0.12em on the baseline. data-s on the caret is \"type\" while typing or erasing (no animation, solid), \"hold\" / \"gap\" / \"idle\" (paused, off screen, reduced motion) otherwise; hold, gap and idle blink with animation twx-blink 1.1s infinite (hard on/off: 0-49% opacity 1, 50-100% opacity 0).\n\nReduced motion (useSyncExternalStore on matchMedia): no typing. Show each whole phrase, cross-fade to the next (class twx-fade: 520ms cubic-bezier(0.2,0.7,0.2,1), opacity 0 + blur 4px to 1) every max(pauseMs + 900, 2400) ms; caret static; also a CSS @media (prefers-reduced-motion: reduce) rule that turns the blink off. Accessibility: animated text aria-hidden, full phrases in the sr-only span, no aria-live.","license":"https://viberdy.dev/license"}}