{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "typing-text",
  "title": "Typing Text",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A typewriter that types and erases one or several strings with natural timing and a blinking caret, starting on scroll if you like, with the whole text available to screen readers.",
  "dependencies": [
    "motion@^12"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/motion-presets.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/typing-text.tsx",
      "content": "// Ballmac UI: Typing Text. https://ui.ballmac.com/components/typing-text\n\"use client\"\n\nimport * as React from \"react\"\nimport { motion, useInView } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useReducedMotionSafe } from \"@/lib/ballmac/motion\"\n\ntype TypingTextProps = Omit<React.ComponentProps<\"span\">, \"children\"> & {\n  /** One string to type, or several to type and erase in turn. */\n  text: string | string[]\n  /** Milliseconds per typed character. */\n  typingSpeed?: number\n  /** Milliseconds per erased character. */\n  deleteSpeed?: number\n  /** Milliseconds to hold a finished string before erasing it. */\n  pause?: number\n  /** Start again after the last string. A single string only loops when this is set. */\n  loop?: boolean\n  /** Show a blinking caret. */\n  cursor?: boolean\n  /** Wait for the text to scroll into view before typing. */\n  startOnView?: boolean\n  /** Called after the final string is fully typed (when not looping). */\n  onComplete?: () => void\n}\n\nfunction TypingText({\n  text,\n  typingSpeed = 55,\n  deleteSpeed = 28,\n  pause = 1600,\n  loop,\n  cursor = true,\n  startOnView = false,\n  onComplete,\n  className,\n  ...props\n}: TypingTextProps) {\n  const list = React.useMemo(() => (Array.isArray(text) ? text : [text]), [text])\n  const looping = loop ?? list.length > 1\n  const reduce = useReducedMotionSafe()\n  const ref = React.useRef<HTMLSpanElement>(null)\n  const seen = useInView(ref, { once: true })\n  const active = !startOnView || seen\n  const [shown, setShown] = React.useState(\"\")\n  const [done, setDone] = React.useState(false)\n  const completeRef = React.useRef(onComplete)\n  React.useEffect(() => {\n    completeRef.current = onComplete\n  })\n\n  React.useEffect(() => {\n    if (!active || reduce) return\n    let cancelled = false\n    let timer: ReturnType<typeof setTimeout>\n    let item = 0\n    let length = 0\n    let erasing = false\n    setDone(false)\n\n    const tick = () => {\n      if (cancelled) return\n      const current = Array.from(list[item] ?? \"\")\n      if (!erasing) {\n        length++\n        setShown(current.slice(0, length).join(\"\"))\n        if (length >= current.length) {\n          const last = item === list.length - 1\n          if (last && !looping) {\n            setDone(true)\n            completeRef.current?.()\n            return\n          }\n          erasing = true\n          timer = setTimeout(tick, pause)\n          return\n        }\n        timer = setTimeout(tick, typingSpeed * (0.7 + ((length * 7) % 5) / 8))\n      } else {\n        length--\n        setShown(current.slice(0, Math.max(length, 0)).join(\"\"))\n        if (length <= 0) {\n          erasing = false\n          item = (item + 1) % list.length\n          timer = setTimeout(tick, typingSpeed * 4)\n          return\n        }\n        timer = setTimeout(tick, deleteSpeed)\n      }\n    }\n    timer = setTimeout(tick, typingSpeed * 3)\n    return () => {\n      cancelled = true\n      clearTimeout(timer)\n    }\n  }, [active, reduce, list, looping, typingSpeed, deleteSpeed, pause])\n\n  const visible = reduce ? (list[looping ? 0 : list.length - 1] ?? \"\") : shown\n  const blinking = !reduce && !done\n\n  return (\n    <span ref={ref} data-slot=\"typing-text\" className={cn(\"inline\", className)} {...props}>\n      <span className=\"sr-only\">{list.join(\". \")}</span>\n      <span aria-hidden=\"true\">\n        {visible}\n        {cursor && !reduce && (\n          <motion.span\n            className=\"ms-px inline-block h-[1.05em] w-[0.1em] translate-y-[0.14em] bg-current\"\n            animate={blinking ? { opacity: [1, 1, 0, 0] } : { opacity: 1 }}\n            transition={blinking ? { duration: 1, repeat: Infinity, times: [0, 0.5, 0.5, 1] } : { duration: 0 }}\n          />\n        )}\n      </span>\n    </span>\n  )\n}\n\nexport { TypingText, type TypingTextProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/typing-text.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "typewriter",
      "typing",
      "text",
      "caret",
      "hero"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "<TypingText text={['one','two']} typingSpeed deleteSpeed pause loop cursor startOnView onComplete />. A single string types once unless loop is set.",
      "whenToUse": [
        "Hero lines that cycle through use cases",
        "Terminal-style intros"
      ],
      "whenNotToUse": [
        "Streaming AI output (streaming-text)",
        "Long copy"
      ],
      "composesWith": [
        "word-rotate",
        "terminal",
        "text-animate"
      ],
      "a11y": [
        {
          "keys": "Screen readers",
          "action": "All strings are available as hidden text; the typing itself is aria-hidden"
        },
        {
          "keys": "Reduced motion",
          "action": "The first string (or the last, when not looping) is shown whole with no caret blink"
        }
      ],
      "customization": [
        "typingSpeed, deleteSpeed, pause",
        "loop",
        "cursor",
        "startOnView",
        "onComplete"
      ]
    },
    "examples": [
      "typing-text-demo",
      "typing-text-once"
    ],
    "url": "https://ui.ballmac.com/components/typing-text"
  },
  "categories": [
    "text"
  ],
  "type": "registry:ui"
}