Skip to content

Simulated stream

Streams a string word by word to stand in for model output.

Status
Beta
Level
Utility
Category
Utilities
Adoption
Not used yet
import { useSimulatedStream } from "@oration/canon/hooks/use-simulated-stream";
packages/canon/src/hooks/use-simulated-stream.ts

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 StreamingText doesn'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 text grows 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

Under reduced motion, movement stops: the hook shows the full text on the first frame and reports 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.

40 characters per second, the default

150, copilot answers

400

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.

Call summary

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.

1 of 3 chunks received
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#

States
StateTreatment
Waitingstart is false: output is empty and progress is 0.
Streamingoutput grows word by word at speed characters per second; progress runs from 0 to 1.
DoneAll text is visible, done is true and onDone has fired once for this run.
Skippedskip() shows the rest at once. It clears when the text changes or on restart().
Continuingtext grew by appending: the reveal carries on from where it was.
Reduced motionFull text immediately, done true.

Behavior#

  • One requestAnimationFrame loop per hook. Each frame works out how many characters speed allows, then extends to the next space or newline, so a word is never cut in half.
  • When text changes and still starts with what's already shown, the reveal continues from there. Any other change starts over from the first character.
  • Setting start to 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.
  • onDone fires once per run and text, read from a ref, so an inline callback doesn't restart the stream.
  • progress is 1 for empty text, so a progress bar never sits at zero on nothing.

Do and don't#

Do. Stream long generated prose: answers, summaries, drafts.
Don't. Stream short UI strings, such as an error message or a button label, which people need whole.
Do. Offer Show full answer (skip) on long answers and keep the rest of the screen usable while it streams.
Don't. Block input until the stream finishes.
Do. Mark the answer aria-busy while streaming, so assistive tech reads it once it's done.
Don't. Put 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-busy on the answer while done is 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) is aria-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.

Props of useSimulatedStream
PropTypeDefaultDescription
textRequiredstringNo defaultThe full text. Appending to it continues the reveal; replacing it starts over.
speednumber40Characters per second. Words are revealed whole. Copilot answers use 150.
startbooleantrueWhile false, output is empty and the stream waits.
onDone() => voidNo defaultCalled once when the whole text is visible.

Returns

SimulatedStream.

Props of Returns
PropTypeDefaultDescription
outputstringNo defaultThe visible part of text.
donebooleanNo defaultTrue when everything is visible and start is true.
progressnumberNo default0 to 1.
restart() => voidNo defaultStream again from the first character.
skip() => voidNo defaultShow 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.