{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "streaming-text",
  "title": "Streaming Text",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Text that arrives progressively, with a blinking caret while streaming, preserved whitespace, aria-busy for screen readers and an optional typewriter reveal for demos.",
  "dependencies": [
    "motion@^12"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/motion-presets.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/streaming-text.tsx",
      "content": "// Ballmac UI: Streaming Text. https://ui.ballmac.com/components/streaming-text\n\"use client\"\n\nimport * as React from \"react\"\nimport { motion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useReducedMotionSafe } from \"@/lib/ballmac/motion\"\n\ntype StreamingCaretProps = React.ComponentProps<\"span\">\n\n/** The blinking block caret shown at the end of text that is still arriving. Static under reduced motion. */\nfunction StreamingCaret({ className, ...props }: StreamingCaretProps) {\n  const reduceMotion = useReducedMotionSafe()\n  const classes = cn(\n    \"ms-0.5 inline-block h-[1.1em] w-[0.5ch] min-w-1.5 translate-y-[0.15em] rounded-[1px] bg-foreground/80\",\n    className\n  )\n  if (reduceMotion) {\n    return <span data-slot=\"streaming-caret\" aria-hidden=\"true\" className={classes} {...props} />\n  }\n  return (\n    <motion.span\n      data-slot=\"streaming-caret\"\n      aria-hidden=\"true\"\n      className={classes}\n      animate={{ opacity: [1, 1, 0, 0] }}\n      transition={{ duration: 1.06, times: [0, 0.5, 0.5, 1], repeat: Infinity, ease: \"linear\" }}\n      {...(props as React.ComponentProps<typeof motion.span>)}\n    />\n  )\n}\n\ntype StreamingTextProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** The text received so far. Append to it as chunks arrive; whitespace and newlines are preserved. */\n  text: string\n  /** True while more text is expected. Shows the caret and sets aria-busy. */\n  streaming?: boolean\n  /** Reveal text that is already complete at `speed` characters per second (for demos and replays). Skipped under reduced motion. */\n  animate?: boolean\n  /** Characters per second when `animate` is on. */\n  speed?: number\n  /** Show the caret while streaming or revealing. */\n  caret?: boolean\n  /** Called once `animate` has revealed the whole text. */\n  onAnimationComplete?: () => void\n}\n\nfunction StreamingText({\n  text,\n  streaming = false,\n  animate = false,\n  speed = 80,\n  caret = true,\n  onAnimationComplete,\n  className,\n  ...props\n}: StreamingTextProps) {\n  const reduceMotion = useReducedMotionSafe()\n  const revealing = animate && !reduceMotion\n  const [count, setCount] = React.useState(0)\n  const countRef = React.useRef(0)\n  const completeRef = React.useRef(onAnimationComplete)\n  React.useEffect(() => {\n    completeRef.current = onAnimationComplete\n  })\n\n  React.useEffect(() => {\n    if (!revealing) return\n    // Text was replaced by something shorter: start over.\n    if (countRef.current > text.length) countRef.current = 0\n    const from = countRef.current\n    if (from >= text.length) return\n    const startedAt = performance.now()\n    let frame = 0\n    const tick = (now: number) => {\n      const next = Math.min(text.length, from + Math.floor(((now - startedAt) / 1000) * speed))\n      if (next !== countRef.current) {\n        countRef.current = next\n        setCount(next)\n      }\n      if (next < text.length) frame = requestAnimationFrame(tick)\n      else completeRef.current?.()\n    }\n    frame = requestAnimationFrame(tick)\n    return () => cancelAnimationFrame(frame)\n  }, [revealing, text, speed])\n\n  const shown = revealing ? text.slice(0, count) : text\n  const busy = streaming || (revealing && count < text.length)\n\n  return (\n    <div\n      data-slot=\"streaming-text\"\n      data-streaming={busy || undefined}\n      aria-live=\"polite\"\n      aria-busy={busy}\n      className={cn(\"whitespace-pre-wrap break-words\", className)}\n      {...props}\n    >\n      {shown}\n      {busy && caret ? <StreamingCaret /> : null}\n    </div>\n  )\n}\n\nexport { StreamingText, StreamingCaret, type StreamingTextProps, type StreamingCaretProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/streaming-text.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai",
      "streaming",
      "typewriter",
      "caret",
      "llm",
      "text"
    ],
    "version": "1.0.0",
    "updated": "2026-09-28",
    "ai": {
      "summary": "Pass the accumulated text and streaming={true} while chunks arrive; it shows a caret and sets aria-busy until you set streaming={false}. Use animate + speed to type out text you already have.",
      "whenToUse": [
        "Assistant replies that arrive token by token from a model",
        "Replaying a saved answer with a typewriter effect in a demo or onboarding",
        "Any plain-text live output where a caret shows that more is coming"
      ],
      "whenNotToUse": [
        "Rendered markdown (render your markdown in ai-message's MessageContent and show StreamingCaret at the end)",
        "Headline text effects (use text-reveal)"
      ],
      "composesWith": [
        "ai-message",
        "ai-chat"
      ],
      "a11y": [
        {
          "keys": "—",
          "action": "aria-live=polite region; aria-busy is true while streaming so screen readers read the finished text instead of every chunk"
        }
      ],
      "customization": [
        "streaming: boolean (caret + aria-busy)",
        "animate + speed (characters per second, default 80) reveal existing text; skipped under reduced motion",
        "caret={false} hides the caret; StreamingCaret is exported for use at the end of custom markup",
        "Whitespace and newlines are preserved (whitespace-pre-wrap)"
      ]
    },
    "examples": [
      "streaming-text-demo"
    ],
    "url": "https://ui.ballmac.com/components/streaming-text"
  },
  "categories": [
    "ai"
  ],
  "type": "registry:ui"
}