{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "typewriter",
  "type": "registry:ui",
  "title": "Typewriter",
  "description": "Types, holds and deletes a rotating list of phrases behind a blinking caret.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/typewriter.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type TypewriterCaret = \"bar\" | \"block\" | \"underscore\";\n\nexport interface TypewriterProps extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n  /** Phrases typed one after another. */\n  words: string[];\n  /** Static text before the animated phrase, e.g. \"We design\". */\n  prefix?: string;\n  /** Element to render. */\n  as?: \"span\" | \"p\" | \"h1\" | \"h2\" | \"h3\" | \"div\";\n  /** Time per typed character, in ms. */\n  typeSpeed?: number;\n  /** Time per deleted character, in ms. */\n  deleteSpeed?: number;\n  /** Hold time once a phrase is complete, in ms. */\n  pause?: number;\n  /** Cycle forever. When false, typing stops on the last phrase. */\n  loop?: boolean;\n  /** Caret shape. */\n  caret?: TypewriterCaret;\n  /** Vary keystroke timing slightly so it feels typed by a person. */\n  jitter?: boolean;\n  /** Reserve the width of the longest phrase so surrounding layout never shifts. */\n  reserveWidth?: boolean;\n  /** Caret colour (any CSS colour). */\n  accent?: string;\n  /** Text for assistive technology. Defaults to the prefix followed by every phrase. */\n  srText?: string;\n}\n\n/* --------------------------- reduced-motion hook --------------------------- */\n\nconst REDUCED_QUERY = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribeReducedMotion(onChange: () => void) {\n  const mq = window.matchMedia(REDUCED_QUERY);\n  mq.addEventListener(\"change\", onChange);\n  return () => mq.removeEventListener(\"change\", onChange);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(REDUCED_QUERY).matches,\n    () => false,\n  );\n}\n\n/* --------------------------------- caret ---------------------------------- */\n\nconst CARET: Record<TypewriterCaret, string> = {\n  bar: \"ml-[0.06em] h-[1.05em] w-[0.07em] min-w-[2px] translate-y-[0.2em] rounded-full\",\n  block: \"ml-[0.08em] h-[1.05em] w-[0.55em] translate-y-[0.2em] rounded-[0.08em]\",\n  underscore: \"ml-[0.06em] h-[0.09em] min-h-[2px] w-[0.55em] rounded-full\",\n};\n\n// Unit separator: never part of real phrases, so the joined key round-trips safely.\nconst SEP = \"␟\";\n\ntype Phase = \"typing\" | \"holding\" | \"deleting\" | \"done\";\n\n/**\n * Types, holds and deletes a rotating list of phrases. Characters are written straight into the\n * DOM from a timer chain (no React render per keystroke); the loop pauses offscreen and in hidden tabs.\n */\nexport function Typewriter({\n  words,\n  prefix,\n  as = \"span\",\n  typeSpeed = 70,\n  deleteSpeed = 35,\n  pause = 1600,\n  loop = true,\n  caret = \"bar\",\n  jitter = true,\n  reserveWidth = false,\n  accent = \"#FF7448\",\n  srText,\n  className = \"\",\n  style,\n  ...props\n}: TypewriterProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLElement>(null);\n  const textRef = React.useRef<HTMLSpanElement>(null);\n  const caretRef = React.useRef<HTMLSpanElement>(null);\n  const Tag = as as React.ElementType;\n\n  // A stable primitive key, so inline `words={[...]}` arrays don't restart the loop every render.\n  const wordsKey = words.join(SEP);\n\n  React.useLayoutEffect(() => {\n    const root = rootRef.current;\n    const textNode = textRef.current;\n    const caretNode = caretRef.current;\n    if (!root || !textNode) return;\n\n    const phrases = wordsKey ? wordsKey.split(SEP).map((w) => Array.from(w)) : [];\n    if (!phrases.length) {\n      textNode.textContent = \"\";\n      return;\n    }\n\n    // Blink only while idle (holding / done); a typing caret stays solid like a real editor.\n    const blink =\n      caretNode && typeof caretNode.animate === \"function\" && !reduceMotion\n        ? caretNode.animate(\n            [\n              { opacity: 1, offset: 0 },\n              { opacity: 1, offset: 0.5 },\n              { opacity: 0, offset: 0.5 },\n              { opacity: 0, offset: 1 },\n            ],\n            { duration: 1000, iterations: Infinity },\n          )\n        : null;\n    const setBlink = (on: boolean) => {\n      if (!blink) return;\n      if (on) blink.play();\n      else {\n        blink.pause();\n        blink.currentTime = 0;\n      }\n    };\n\n    if (reduceMotion) {\n      textNode.textContent = phrases[0].join(\"\");\n      return;\n    }\n\n    let wordIndex = 0;\n    let charCount = 0;\n    let phase: Phase = \"typing\";\n    let timer = 0;\n    let inView = false;\n    let running = false;\n\n    textNode.textContent = \"\";\n    setBlink(false);\n\n    const humanize = (base: number, char?: string) => {\n      if (!jitter) return base;\n      // ±40% per keystroke, plus a short beat after spaces and punctuation.\n      const beat = char && /[\\s,.;:!?]/.test(char) ? base * 0.9 : 0;\n      return base * (0.6 + Math.random() * 0.8) + beat;\n    };\n\n    const schedule = (ms: number) => {\n      window.clearTimeout(timer);\n      timer = window.setTimeout(step, Math.max(0, ms));\n    };\n\n    function step() {\n      const word = phrases[wordIndex];\n      if (phase === \"typing\") {\n        if (charCount < word.length) {\n          charCount += 1;\n          textNode!.textContent = word.slice(0, charCount).join(\"\");\n          schedule(humanize(typeSpeed, word[charCount - 1]));\n          return;\n        }\n        const isLast = wordIndex === phrases.length - 1;\n        phase = !loop && isLast ? \"done\" : \"holding\";\n        setBlink(true);\n        if (phase === \"holding\") schedule(pause);\n        return;\n      }\n      if (phase === \"holding\") {\n        phase = \"deleting\";\n        setBlink(false);\n        schedule(deleteSpeed);\n        return;\n      }\n      if (phase === \"deleting\") {\n        if (charCount > 0) {\n          charCount -= 1;\n          textNode!.textContent = word.slice(0, charCount).join(\"\");\n          schedule(humanize(deleteSpeed));\n          return;\n        }\n        wordIndex = (wordIndex + 1) % phrases.length;\n        phase = \"typing\";\n        schedule(Math.max(typeSpeed * 3, 180));\n      }\n    }\n\n    const sync = () => {\n      const shouldRun = inView && !document.hidden;\n      if (shouldRun === running) return;\n      running = shouldRun;\n      if (running) {\n        if (phase === \"holding\" || phase === \"done\") setBlink(true);\n        if (phase !== \"done\") schedule(phase === \"holding\" ? pause : typeSpeed);\n      } else {\n        window.clearTimeout(timer);\n        blink?.pause();\n      }\n    };\n\n    const observer = new IntersectionObserver((entries) => {\n      inView = entries.some((entry) => entry.isIntersecting);\n      sync();\n    });\n    observer.observe(root);\n    document.addEventListener(\"visibilitychange\", sync);\n\n    return () => {\n      window.clearTimeout(timer);\n      observer.disconnect();\n      document.removeEventListener(\"visibilitychange\", sync);\n      blink?.cancel();\n    };\n    // reserveWidth swaps the DOM nodes the loop writes into, so it restarts the loop too.\n  }, [wordsKey, typeSpeed, deleteSpeed, pause, loop, jitter, reduceMotion, reserveWidth]);\n\n  const spoken = srText ?? [prefix, words.join(\", \")].filter(Boolean).join(\" \");\n  const caretNode = (\n    <span\n      ref={caretRef}\n      className={`inline-block shrink-0 bg-[var(--typewriter-accent)] align-baseline ${CARET[caret] ?? CARET.bar}`}\n    />\n  );\n\n  return (\n    <Tag\n      ref={rootRef}\n      className={className}\n      style={{ [\"--typewriter-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <span className=\"sr-only\">{spoken}</span>\n      <span aria-hidden=\"true\">\n        {prefix ? <>{prefix} </> : null}\n        {reserveWidth ? (\n          // Every phrase sits invisibly in the same grid cell, so the box is as wide as the longest one.\n          <span className=\"inline-grid whitespace-pre align-bottom\">\n            {words.map((word, i) => (\n              <span key={i} className=\"invisible col-start-1 row-start-1\">\n                {word}\n                <span className={`inline-block ${CARET[caret] ?? CARET.bar}`} />\n              </span>\n            ))}\n            <span className=\"col-start-1 row-start-1 justify-self-start\">\n              <span ref={textRef} />\n              {caretNode}\n            </span>\n          </span>\n        ) : (\n          <span className=\"whitespace-pre-wrap\">\n            <span ref={textRef} />\n            {caretNode}\n          </span>\n        )}\n      </span>\n    </Tag>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "docs": "Documentation: https://wancraft.my/components/typewriter"
}
