Simulated stream
Streams a string word by word to stand in for model output.
What does Cedarline owe Northwind Freight?
import { StreamCaret } from "@oration/canon/components/ai/streaming-text";import { Button } from "@oration/canon/components/button";import { useSimulatedStream } from "@oration/canon/hooks/use-simulated-stream";import { RotateCcwIcon } from "lucide-react";export function Hero() { const answer = "Northwind Freight has 14 open invoices totaling $42,180.00. Nine are scheduled for Friday's payment run. The other five are waiting on goods receipts from the Tacoma warehouse, and two of those are past their Net 30 terms."; const stream = useSimulatedStream({ text: answer, speed: 150 }); return ( <div className="flex w-full max-w-lg flex-col gap-3 rounded-xl bg-card p-4 shadow-border"> <p className="text-13 text-muted-foreground"> What does Cedarline owe Northwind Freight? </p> <p aria-busy={!stream.done} className="min-h-24 text-sm leading-6 text-pretty text-foreground" > {stream.output} {stream.done ? null : <StreamCaret />} </p> <div className="flex gap-2"> {stream.done ? ( <Button type="button" variant="ghost" size="sm" onClick={stream.restart} > <RotateCcwIcon data-icon="inline-start" aria-hidden="true" /> Regenerate </Button> ) : ( <Button type="button" variant="ghost" size="sm" onClick={stream.skip} > Show full answer </Button> )} </div> </div> );}Usage#
useSimulatedStream reveals a string over time on one animation-frame loop, whole words at a time, so mock answers read like a model streaming tokens. It returns the visible output plus done, progress, restart and skip, and it powers StreamingText, copilot answers and call wrap-up summaries. Under reduced motion the full text shows at once. The usual mistake is streaming text people need immediately, such as an error, a label or a value they're about to act on.
When to use
- Mock model output in copilot and agent answers, when
StreamingTextdoesn't fit the layout. - Generated summaries and drafts: call wrap-ups, a reply draft, an invoice exception explanation.
- Driving a custom renderer (markdown, citations, a caret) from
output. - Text that arrives in chunks: when
textgrows by appending, the reveal continues instead of starting over.
When not to use
- A standard streamed answer with caret, citations and follow-ups. The component already wraps this hook. Use Streaming text
- Content that is loading. Show a skeleton, then the content. Use Skeleton
- Errors, toasts, labels and status text. They appear whole. Use Toast
- Numbers that change. Use Animated number
- A typing effect on headings or marketing copy. Streaming signals generated output and nothing else.
Reduced motion
done, so onDone still fires.Examples#
Speed
speed is characters per second, rounded out to whole words. The default 40 reads like a slow model; copilot answers use 150.
import { StreamCaret } from "@oration/canon/components/ai/streaming-text";import { useSimulatedStream } from "@oration/canon/hooks/use-simulated-stream";export function Speeds() { const text = "Remittance advice for RMT-4410 went to ap@northwindfreight.com on Monday at 9:14 AM."; const standard = useSimulatedStream({ text }); const copilot = useSimulatedStream({ text, speed: 150 }); const quick = useSimulatedStream({ text, speed: 400 }); const rows = [ { label: "40 characters per second, the default", stream: standard }, { label: "150, copilot answers", stream: copilot }, { label: "400", stream: quick }, ]; return ( <div className="flex w-full max-w-lg flex-col gap-4"> {rows.map((row) => ( <div key={row.label} className="flex flex-col gap-1"> <span className="text-xs text-muted-foreground"> {row.label} </span> <p aria-busy={!row.stream.done} className="min-h-10 text-13 text-foreground" > {row.stream.output} {row.stream.done ? null : <StreamCaret />} </p> </div> ))} </div> );}Start, progress and done
start holds the stream until the person asks, progress drives an ink bar, and onDone fires once per run.
No summary yet. It takes about two seconds.
import { StreamCaret } from "@oration/canon/components/ai/streaming-text";import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import { useSimulatedStream } from "@oration/canon/hooks/use-simulated-stream";import * as React from "react";export function StartOnDemand() { const [start, setStart] = React.useState(false); const summary = "Jordan Lee called Halcyon Packaging about INV-20944. Halcyon confirmed the corrected amount of $6,215.50 and will resend the invoice today. Next step: approve it for Friday's payment run."; const stream = useSimulatedStream({ text: summary, speed: 120, start, onDone: () => toast.add({ type: "success", title: "Call summary ready" }), }); const percent = Math.round(stream.progress * 100); return ( <div className="flex w-full max-w-lg flex-col gap-3 rounded-xl bg-card p-4 shadow-border"> <div className="flex items-center justify-between gap-3"> <span className="text-sm font-medium text-foreground"> Call summary </span> <Button type="button" variant="outline" size="sm" disabled={start && !stream.done} onClick={() => (start ? stream.restart() : setStart(true))} > {start ? "Regenerate" : "Summarize call"} </Button> </div> <div role="progressbar" aria-label="Summary progress" aria-valuenow={percent} aria-valuemin={0} aria-valuemax={100} className="h-1 overflow-hidden rounded-full bg-muted" > <div className="h-full origin-left bg-foreground/65" style={{ transform: `scaleX(${stream.progress})` }} /> </div> <p aria-busy={start && !stream.done} className="min-h-18 text-sm leading-6 text-pretty text-foreground" > {start ? ( <> {stream.output} {stream.done ? null : <StreamCaret />} </> ) : ( <span className="text-muted-foreground"> No summary yet. It takes about two seconds. </span> )} </p> </div> );}Text that arrives in chunks
When text grows by appending, as a real stream would feed it, the reveal continues from where it was. Replacing the text starts over.
import { StreamCaret } from "@oration/canon/components/ai/streaming-text";import { Button } from "@oration/canon/components/button";import { useSimulatedStream } from "@oration/canon/hooks/use-simulated-stream";import * as React from "react";export function AppendingText() { const chunks = [ "Checking the W-9 on file for Orchard Street Supply.", " The form was signed on March 3, 2026 and lists an EIN ending in 4471.", " That matches the EIN on their latest invoice, so no new W-9 is needed.", ]; const [received, setReceived] = React.useState(1); const text = chunks.slice(0, received).join(""); const stream = useSimulatedStream({ text, speed: 90 }); return ( <div className="flex w-full max-w-lg flex-col gap-3"> <p aria-busy={!stream.done || received < chunks.length} className="min-h-18 text-sm leading-6 text-pretty text-foreground" > {stream.output} {received < chunks.length || !stream.done ? ( <StreamCaret /> ) : null} </p> <div className="flex flex-wrap items-center gap-2"> <Button type="button" variant="outline" size="sm" disabled={received === chunks.length} onClick={() => setReceived((count) => count + 1)} > Receive next chunk </Button> <Button type="button" variant="ghost" size="sm" disabled={received === 1} onClick={() => setReceived(1)} > Start over </Button> <span className="text-xs text-muted-foreground tabular-nums"> {received} of {chunks.length} chunks received </span> </div> </div> );}States#
| State | Treatment |
|---|---|
| Waiting | start is false: output is empty and progress is 0. |
| Streaming | output grows word by word at speed characters per second; progress runs from 0 to 1. |
| Done | All text is visible, done is true and onDone has fired once for this run. |
| Skipped | skip() shows the rest at once. It clears when the text changes or on restart(). |
| Continuing | text grew by appending: the reveal carries on from where it was. |
| Reduced motion | Full text immediately, done true. |
Behavior#
- One
requestAnimationFrameloop per hook. Each frame works out how many charactersspeedallows, then extends to the next space or newline, so a word is never cut in half. - When
textchanges and still starts with what's already shown, the reveal continues from there. Any other change starts over from the first character. - Setting
startto false resets the output to empty; setting it back to true streams from the beginning. restart()replays from the first character and clears a skip.skip()reveals everything at once.onDonefires once per run and text, read from a ref, so an inline callback doesn't restart the stream.progressis 1 for empty text, so a progress bar never sits at zero on nothing.
Do and don't#
skip) on long answers and keep the rest of the screen usable while it streams.aria-busy while streaming, so assistive tech reads it once it's done.aria-live on the streaming text, which announces every word as it lands.Content#
- Streamed text is the answer itself. Don't prefix it with Thinking… or Typing…; the caret is the only streaming indicator.
- Lead with the answer and its figures: Northwind Freight has 14 open invoices totaling $42,180.00.
- Label the controls Show full answer and Regenerate, not Skip or Retry.
Accessibility#
- Set
aria-busyon the answer whiledoneis false, and drop it when the stream ends. - Never put a live region on the element that streams. Announce completion once if the answer appears away from focus.
- The caret (
StreamCaret) isaria-hidden. - Reduced motion is handled in the hook; the text appears whole.
API reference#
useSimulatedStream
useSimulatedStream(options: SimulatedStreamOptions): SimulatedStream, from @oration/canon/hooks/use-simulated-stream.
| Prop | Type | Default | Description |
|---|---|---|---|
textRequired | string | No default | The full text. Appending to it continues the reveal; replacing it starts over. |
speed | number | 40 | Characters per second. Words are revealed whole. Copilot answers use 150. |
start | boolean | true | While false, output is empty and the stream waits. |
onDone | () => void | No default | Called once when the whole text is visible. |
Returns
SimulatedStream.
| Prop | Type | Default | Description |
|---|---|---|---|
output | string | No default | The visible part of text. |
done | boolean | No default | True when everything is visible and start is true. |
progress | number | No default | 0 to 1. |
restart | () => void | No default | Stream again from the first character. |
skip | () => void | No default | Show the rest of the text now. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
The registry describes it as streaming character by character; the code reveals whole words.
Accessibility is left to the caller: the hook doesn't set aria-busy or announce completion, so each call site has to remember to.