Streaming text

Free · MIT

Reveals an answer token by token at a steady speed with a cursor that blinks while waiting for more, stop and resume, word or character mode, and smooth catch-up when uneven SSE chunks arrive; reduced motion shows sentence chunks.

MarkdownOpen in v0
Open in v0

Installation

$ npx shadcn@latest add https://goodui.dev/r/streaming-text.json

The file lands in components/goodui/streaming-text.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/streaming-text works too.

Usage

import { StreamingText } from "@/components/goodui/streaming-text"

export function Answer({ text, done }: { text: string; done: boolean }) {
  const [playing, setPlaying] = React.useState(true)
  return (
    <div>
      <p className="text-[15px] leading-7">
        <StreamingText text={text} streaming={!done} playing={playing} speed={60} />
      </p>
      <button type="button" onClick={() => setPlaying((p) => !p)}>{playing ? "Stop" : "Resume"}</button>
    </div>
  )
}

When to use

Use it for
  • Assistant answers that arrive over SSE or websockets: pass the accumulated text and the reveal catches up smoothly instead of jumping with every chunk.
  • Pre-written text that should read like it is being generated (demos, onboarding assistants).
  • A Stop button: set playing to false and the text freezes where it is; true resumes.
Reach for something else
  • Marketing typewriter loops that type and delete phrases: use typewriter.
  • Markdown-formatted answers: use chat-message's content, which keeps its own caret.
  • Text that must be read instantly (errors, form feedback): render it plainly.
If you needUse
Live API streamtext = all chunks so far, streaming until the final event
Whole words appearingby="word"
Restore history without replayinitialRevealed="all"
Replaychange resetKey
Cursor stylecursor="bar" | "block" | "dot" | false

Examples

Word by word with a block cursor

<StreamingText text={summary} by="word" cursor="block" speed={40} />

Restored message, no animation

<StreamingText text={message} initialRevealed="all" />

Progress callback for an auto-scrolling list

<StreamingText text={text} onProgress={() => listRef.current?.scrollTo({ top: 1e9 })} />

API reference

PropTypeDefault
text*
All the text received so far. Growing it continues the stream; a different string starts over.
string—
onProgress
Called as the reveal advances, with visible and total character counts.
(revealed: number, total: number) => void—
speed
Characters per second at a steady state. When a lot of text is waiting the reveal speeds up so it catches up within about a second.
number60
by
Reveal unit: single characters or whole words.
"char" | "word""char"
playing
false pauses the reveal where it is (a Stop button); true resumes.
booleantrue
streaming
More text may still arrive: the cursor stays and blinks while waiting. Set false once the response is complete.
booleanfalse
cursor
Cursor style, or false for none.
"bar" | "block" | "dot" | false"bar"
cursorWhenDone
Keep the cursor after the text is complete and not streaming.
booleanfalse
fade
New characters fade in briefly instead of popping.
booleantrue
initialRevealed
Characters already visible at mount, or "all" to show restored history without replaying it.
number | "all"0
resetKey
Change it to replay from the start.
React.Key—
onComplete
Called when every character of `text` is visible and `streaming` is false.
() => void—
labels
Text for screen readers; pass translations here.
Partial<StreamingTextLabels>—

Also accepts every <span> attribute.

Accessibility

  • While text is still being revealed, screen readers get the full text in a visually hidden span and the animated copy is aria-hidden; once complete and not streaming, the visible text is the only copy.
  • A visually hidden "Streaming" label (labels.streaming) is present while the reveal is active.
  • Reduced motion replaces character-by-character reveal with sentence-sized chunks every 140 ms, and the cursor does not blink.

Motion

  • Characters are revealed on a requestAnimationFrame loop at `speed` characters per second; when the unread backlog grows (a burst of chunks) the rate rises so it never lags more than about a second behind the stream.
  • The last 18 characters fade in with a 0.3 s blur-to-sharp CSS animation (fade).
  • The cursor is solid while text is arriving and blinks at 1 Hz while waiting for more; the dot cursor pulses instead.
  • Reduced motion: chunks appear whole, no fade, static cursor.
  • Only the last 18 characters are individual spans; the rest is one text node. The loop stops when the text is fully revealed, when playing is false and when the tab is hidden (requestAnimationFrame pauses in background tabs).

Notes for AI

Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.

  • Always pass the whole text received so far, not the latest chunk; the component works out what is new.
  • Keep streaming true until the final event; it keeps the cursor and the hidden full-text copy in place. onComplete fires only when everything is revealed and streaming is false.
  • A text that does not start with what was already shown is treated as a new message and starts from zero.
  • Use resetKey to replay the same text; initialRevealed="all" to show restored history instantly.
  • The root is a <span> with whitespace-pre-wrap; add className="block" to use it as a block.

FAQ

Why not just render the chunks as they come?

Chunk sizes and intervals are uneven, so plain rendering stutters. The steady rate with catch-up reads as one calm stream.

Can I render markdown with it?

Not directly; it reveals plain text. chat-message renders markdown with its own streaming caret.

Related