Mischief

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.

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/streaming-text
import { 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.

registry/default/streaming-text/streaming-text.tsx
"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?: string

Usage

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}
/>
Anything async-iterable works, including a fetch body reader.

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.