Text that arrives progressively, with a blinking caret while streaming, preserved whitespace, aria-busy for screen readers and an optional typewriter reveal for demos.
import { StreamingText } from "@/components/ballmac/streaming-text"
export default function StreamingTextDemo() {
return (
<div className="w-full max-w-lg text-sm leading-7">
<StreamingText
animate
speed={60}
text={"The build failed because `motion` wasn't installed.\nRun the add command again and the CLI will install it for you."}
/>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/streaming-textInstall the dependencies.
$ pnpm add motion@^12Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/motion-presetsCopy the source into your project.
components/ballmac/streaming-text.tsx// Ballmac UI: Streaming Text. https://ui.ballmac.com/components/streaming-text "use client" import * as React from "react" import { motion } from "motion/react" import { cn } from "@/lib/utils" import { useReducedMotionSafe } from "@/lib/ballmac/motion" type StreamingCaretProps = React.ComponentProps<"span"> /** The blinking block caret shown at the end of text that is still arriving. Static under reduced motion. */ function StreamingCaret({ className, ...props }: StreamingCaretProps) { const reduceMotion = useReducedMotionSafe() const classes = cn( "ms-0.5 inline-block h-[1.1em] w-[0.5ch] min-w-1.5 translate-y-[0.15em] rounded-[1px] bg-foreground/80", className ) if (reduceMotion) { return <span data-slot="streaming-caret" aria-hidden="true" className={classes} {...props} /> } return ( <motion.span data-slot="streaming-caret" aria-hidden="true" className={classes} animate={{ opacity: [1, 1, 0, 0] }} transition={{ duration: 1.06, times: [0, 0.5, 0.5, 1], repeat: Infinity, ease: "linear" }} {...(props as React.ComponentProps<typeof motion.span>)} /> ) } type StreamingTextProps = Omit<React.ComponentProps<"div">, "children"> & { /** The text received so far. Append to it as chunks arrive; whitespace and newlines are preserved. */ text: string /** True while more text is expected. Shows the caret and sets aria-busy. */ streaming?: boolean /** Reveal text that is already complete at `speed` characters per second (for demos and replays). Skipped under reduced motion. */ animate?: boolean /** Characters per second when `animate` is on. */ speed?: number /** Show the caret while streaming or revealing. */ caret?: boolean /** Called once `animate` has revealed the whole text. */ onAnimationComplete?: () => void } function StreamingText({ text, streaming = false, animate = false, speed = 80, caret = true, onAnimationComplete, className, ...props }: StreamingTextProps) { const reduceMotion = useReducedMotionSafe() const revealing = animate && !reduceMotion const [count, setCount] = React.useState(0) const countRef = React.useRef(0) const completeRef = React.useRef(onAnimationComplete) React.useEffect(() => { completeRef.current = onAnimationComplete }) React.useEffect(() => { if (!revealing) return // Text was replaced by something shorter: start over. if (countRef.current > text.length) countRef.current = 0 const from = countRef.current if (from >= text.length) return const startedAt = performance.now() let frame = 0 const tick = (now: number) => { const next = Math.min(text.length, from + Math.floor(((now - startedAt) / 1000) * speed)) if (next !== countRef.current) { countRef.current = next setCount(next) } if (next < text.length) frame = requestAnimationFrame(tick) else completeRef.current?.() } frame = requestAnimationFrame(tick) return () => cancelAnimationFrame(frame) }, [revealing, text, speed]) const shown = revealing ? text.slice(0, count) : text const busy = streaming || (revealing && count < text.length) return ( <div data-slot="streaming-text" data-streaming={busy || undefined} aria-live="polite" aria-busy={busy} className={cn("whitespace-pre-wrap break-words", className)} {...props} > {shown} {busy && caret ? <StreamingCaret /> : null} </div> ) } export { StreamingText, StreamingCaret, type StreamingTextProps, type StreamingCaretProps }Update the import paths to match your project setup.
Usage
import { StreamingText, StreamingCaret } from "@/components/ballmac/streaming-text"The full example is in the Code tab above.
API reference
| Prop | Type | Default |
|---|---|---|
text*The text received so far. Append to it as chunks arrive; whitespace and newlines are preserved. | string | — |
streamingTrue while more text is expected. Shows the caret and sets aria-busy. | boolean | false |
animateReveal text that is already complete at `speed` characters per second (for demos and replays). Skipped under reduced motion. | boolean | false |
speedCharacters per second when `animate` is on. | number | 80 |
caretShow the caret while streaming or revealing. | boolean | true |
onAnimationCompleteCalled once `animate` has revealed the whole text. | () => void | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| — | aria-live=polite region; aria-busy is true while streaming so screen readers read the finished text instead of every chunk |
Use with AI
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. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Streaming Text (@ballmac/streaming-text) to this project with the shadcn MCP, then use it where it fits.
Use it for
- 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
Not for
- Rendered markdown (render your markdown in ai-message's MessageContent and show StreamingCaret at the end)
- Headline text effects (use text-reveal)
Registry JSON: https://ui.ballmac.com/r/streaming-text.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
Pairs well with
AI Message
A chat message with user, assistant and system roles: right-aligned user bubbles, full-width assistant prose, an avatar slot, hover-revealed actions and a hydration-safe timestamp.
AI Chat
The layout for a chat UI: a message log that sticks to the bottom while replies stream unless the reader scrolls up, a jump-to-latest button, an empty state with suggestions and a footer.
Agent Plan
A live task plan for agents: a vertical timeline with pending, running, done, failed and skipped steps, nested substeps, expandable output, segmented progress and retry on failure.
AI Orb
A glass orb of drifting theme-colored light that shows whether the assistant is idle, listening, thinking or speaking, and swells with a live voice level.