21 / Agent UI
Streaming Text
Text that arrives a piece at a time from an async source, with a cursor while it runs and sentence-level announcements for screen readers.
"use client"
import * as React from "react"
import { RestartButton } from "@/components/demos/restart-button"
import { StreamingText } from "mischief-ui/streaming-text"
const script =
"I checked the three files you changed. The migration looks right, but `users.email` is still nullable, so the unique index will fail on the second empty row. Want me to add the backfill first?"
export function StreamingTextDemo() {
const [runId, setRunId] = React.useState(0)
const [done, setDone] = React.useState(false)
return (
<div className="grid w-full max-w-xl gap-4">
<StreamingText
key={runId}
text={script}
speed={55}
className="text-[0.95rem] leading-relaxed"
onDone={() => setDone(true)}
/>
{done ? (
<RestartButton
onClick={() => {
setDone(false)
setRunId((current) => current + 1)
}}
/>
) : null}
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/streaming-textimport { StreamingText } from "mischief-ui/streaming-text"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react"import { cn } from "@/lib/utils" export type StreamSource = AsyncIterable<string> | ReadableStream<string> export type StreamingTextStatus = "idle" | "streaming" | "done" | "error" export type StreamingTextProps = Omit< React.HTMLAttributes<HTMLDivElement>, "children"> & { text?: stringUsage
export function Answer({ stream }: { stream: AsyncIterable<string> }) {
return <StreamingText source={stream} onDone={saveAnswer} />
}Two ways to drive it
Pass text and it is typed out at speed, which is the right thing for a canned answer or a demonstration. Pass source -- an async iterable of chunks -- and it renders what actually arrives, at the pace it arrives, with no artificial delay in front of a real response.
<StreamingText
source={response.body}
onDone={(text) => save(text)}
onError={report}
/>Callbacks fire from the status they describe rather than from inside a render, so onDone runs once when the stream finishes and never during React's own work.
What a screen reader hears
Announcing every character would be unusable, so the live region is filled a sentence at a time as sentences complete. A reader hears the answer in whole thoughts, slightly behind the text on screen, instead of a stream of letters.
Set announce to off where the text is decorative, or where something else on the page is already announcing the same content. A static render -- no streaming, no source -- fills nothing, so a transcript of past messages does not re-announce itself on mount.
API
textstringStatic content, or the script replayed by speed.sourceAsyncIterable<string> | ReadableStream<string>A live source consumed once and appended as it arrives.speednumberCharacters per second when replaying text. Defaults to 0, which renders instantly.streamingbooleanForces the streaming state when the caller owns the text.cursorReactNode | falseReplaces or removes the trailing cursor.announce"sentences" | "off"How the live region reports progress. Defaults to "sentences".onDone(text: string) => voidRuns once the source completes.onError(error: unknown) => voidRuns when the source rejects.onStatusChange(status: StreamingTextStatus) => voidRuns on every status transition.Accessibility
While text is arriving the visible node is hidden from assistive technology and a polite live region receives completed sentences instead, flushed on terminal punctuation, after a one second pause, or on completion. When the source settles the visible text is exposed normally and the live region is cleared. Static text never populates a live region. The cursor stops animating under reduced motion.