Skip to content

Streaming text

Model output that streams in with a caret, citations and follow-up chips.

Status
Stable
Category
AI
Adoption
Not used yet
import { StreamingText } from "@oration/canon/components/ai/streaming-text";
packages/canon/src/components/ai/streaming-text.tsx

Summary

from the linked call and thread

Sources2

  1. 1Call transcriptVoice call with Halcyon“The payment for INV-20931 came in $412.50 short, and we can't match it to anything.”
  2. 2ERP paymentPayment PMT-58213ACH, $20,212.50, sent Sep 25. Discount applied: $412.50 (2/10 net 30).
import { type Source } from "@oration/canon/components/ai/source-cards";import { StreamingText } from "@oration/canon/components/ai/streaming-text";import { Button } from "@oration/canon/components/button";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { RotateCcwIcon, SparklesIcon } from "lucide-react";import * as React from "react";export function Hero() {    const [run, setRun] = React.useState(0);    const sources: Source[] = [        {            id: "cv_48213",            kind: "transcript",            title: "Voice call with Halcyon",            snippet:                "“The payment for INV-20931 came in $412.50 short, and we can't match it to anything.”",            meta: "Call transcript",        },        {            id: "pmt-58213",            kind: "record",            title: "Payment PMT-58213",            snippet:                "ACH, $20,212.50, sent Sep 25. Discount applied: $412.50 (2/10 net 30).",            meta: "ERP payment",        },    ];    return (        <section            aria-labelledby="hero-summary-title"            className="w-full max-w-xl rounded-xl bg-card p-4 text-left shadow-border"        >            <header className="mb-2 flex items-center gap-2">                <SparklesIcon                    className="size-4 text-muted-foreground"                    aria-hidden="true"                />                <h3                    id="hero-summary-title"                    className="text-13 font-medium text-foreground"                >                    Summary                </h3>                <span className="text-xs text-muted-foreground">                    from the linked call and thread                </span>                <Tooltip>                    <TooltipTrigger                        render={                            <Button                                type="button"                                variant="ghost"                                size="icon-xs"                                aria-label="Regenerate summary"                                className="ml-auto text-muted-foreground"                                onClick={() => setRun((r) => r + 1)}                            />                        }                    >                        <RotateCcwIcon aria-hidden="true" />                    </TooltipTrigger>                    <TooltipContent>Regenerate</TooltipContent>                </Tooltip>            </header>            <StreamingText                key={run}                text={                    "Halcyon's AR team called about invoice **INV-20931**, which was paid $412.50 short [1]. Payment PMT-58213 went out on September 25 for $20,212.50 with the 2% early-pay discount from their 2/10 net 30 terms [2].\n\n**Next step:** reply with the remittance advice, which lists the discount as its own line."                }                speed={run === 0 ? 240 : 180}                citations={sources}                className="text-sm leading-relaxed text-pretty"            />        </section>    );}

Usage#

Streaming text renders model output as it arrives: words appear with a soft caret, simple markdown formats as it goes, [n] markers become citations that point at source cards, and follow-up chips appear once it finishes. It is the AI summary block on every page that summarizes something, such as a ticket, a call or a supplier. The common mistake is streaming text that already exists. Stream when an answer is new or regenerated; when someone comes back to it, show it as plain text.

When to use

  • For the AI summary on a detail page: a ticket, a call, a supplier, a handoff.
  • For an answer in a retrieval test or an assist panel, with its sources.
  • When someone presses Regenerate: remount it and the new text streams in.
  • With followUps when there are two or three obvious next questions.
  • As the model for your own stream: StreamCaret with SimpleMarkdown and useSimulatedStream.

When not to use

  • To fill a form field with a draft the person will edit. Use AI generate button
  • For a conversation with messages, actions and branches. Use AI message
  • For a proposed edit to text that already exists. Use Diff
  • For the wait before any text exists. Use AI loader
  • For a long prompt someone writes and edits. Use Prompt editor
  • For a summary that was already read. Render it without streaming, as the handoff summary does after its first view.

AI everywhere, without noise

Pages that summarize something get one AI summary block, built on streaming text with its sources. It sits in its own section with a Summary heading and a regenerate action, and doesn't restream on every visit.

The One Filled Button Rule

Follow-ups are outline chips at 28px. They are suggestions, never the view's filled action.

The Thirteen-Fourteen Rule

Summaries are reading text at 14px. In dense panels, such as a knowledge base test, pass className="text-13".

Anatomy#

PMT-58213 went out on September 25 with the early-pay

  1. 1ERP paymentPayment PMT-58213ACH, $20,212.50, sent Sep 25.
  1. Text. Simple markdown at 14px with relaxed leading: paragraphs, # headings as bold lines, bullet and numbered lists, bold and code.
  2. Citation. Each [n] becomes a marker for citations[n − 1], with a hover preview.
  3. Caret. A 2px rounded bar in ink at 55%, blinking on a 1.1s cycle at the end of the last block while text streams.
  4. Sources. Source cards for citations, in a row by default.
  5. Follow-ups. Outline 28px chips with a corner-arrow icon. They rise in one after another once the text is done.

Examples#

Speed

Characters per second, revealed a word at a time. 40 is the default; product summaries run at 110 to 420 so a paragraph lands in a second or two.

import { StreamingText } from "@oration/canon/components/ai/streaming-text";import { SegmentedControl } from "@oration/canon/components/segmented-control";import * as React from "react";export function Speed() {    const [speed, setSpeed] = React.useState<"40" | "180" | "420">("180");    return (        <div className="flex w-full max-w-lg flex-col gap-4">            <SegmentedControl                label="Speed"                value={speed}                onValueChange={setSpeed}                options={[                    { value: "40", label: "40 chars/s" },                    { value: "180", label: "180" },                    { value: "420", label: "420" },                ]}                className="self-start"            />            <StreamingText                key={speed}                speed={Number(speed)}                text="Orchard Street asked why invoice OS-4471 hasn't been paid. It was approved on September 22 and paid by ACH on Friday, September 25. The agent sent the trace number and resent the remittance to their AP inbox."            />        </div>    );}

Formatting

Paragraphs, # headings, bullet and numbered lists, bold and inline code. Markers that are half typed stay hidden until they close.

import { StreamingText } from "@oration/canon/components/ai/streaming-text";export function Formatting() {    return (        <div className="w-full max-w-lg">            <StreamingText                speed={260}                text={                    'Two instructions in the **Payments desk agent** prompt cause most failed calls:\n\n- It insists on the full invoice number, so callers who read the last four digits get asked again.\n- It says "processing" for scheduled payments instead of reading `{{payment_run_date}}`.\n\n## What the edit changes\n\n1. Accept the last four digits with the supplier name.\n2. Read the run date for scheduled payments.'                }            />        </div>    );}

Citations and sources

[n] markers point at citations[n − 1]. Choose the card layout, or hide the cards with showSources={false} when you show sources elsewhere.

Sources2

  1. 1Payment security95% matchBank change verificationBank changes need a verified callback to the number on file before the change is applied.
  2. 2Portal83% matchUpdating details in the supplier portalSuppliers can submit new bank details in the portal; they apply after verification.
import { type Source } from "@oration/canon/components/ai/source-cards";import { StreamingText } from "@oration/canon/components/ai/streaming-text";import { SegmentedControl } from "@oration/canon/components/segmented-control";import * as React from "react";export function SourcesLayout() {    const [mode, setMode] = React.useState<"row" | "list" | "hidden">("list");    const sources: Source[] = [        {            id: "kb-bank",            kind: "doc",            title: "Bank change verification",            snippet:                "Bank changes need a verified callback to the number on file before the change is applied.",            meta: "Payment security",            score: 0.95,        },        {            id: "kb-portal",            kind: "doc",            title: "Updating details in the supplier portal",            snippet:                "Suppliers can submit new bank details in the portal; they apply after verification.",            meta: "Portal",            score: 0.83,        },    ];    return (        <div className="flex w-full max-w-md flex-col gap-4">            <SegmentedControl                label="Sources"                value={mode}                onValueChange={setMode}                options={[                    { value: "row", label: "Row" },                    { value: "list", label: "List" },                    { value: "hidden", label: "Hidden" },                ]}                className="self-start"            />            <StreamingText                key={mode}                speed={220}                className="text-13"                citations={sources}                showSources={mode !== "hidden"}                sourcesLayout={mode === "row" ? "row" : "list"}                text="No. Bank changes need a verified callback to the number already on file [1]. Point the supplier to the portal, where new details apply after that callback [2]."            />        </div>    );}

Follow-ups

Chips appear once the text is done and hand their text to onFollowUp. In the product they go to Copilot, confirmed with a toast.

Summary

import { StreamingText } from "@oration/canon/components/ai/streaming-text";import { toast } from "@oration/canon/components/toast";import { SparklesIcon } from "lucide-react";export function FollowUps() {    return (        <section className="flex w-full max-w-xl flex-col gap-2 rounded-xl bg-card p-4 text-left shadow-border">            <h3 className="flex items-center gap-1.5 text-13 font-medium text-foreground">                <SparklesIcon                    aria-hidden="true"                    className="size-3.5 text-muted-foreground"                />                Summary            </h3>            <StreamingText                speed={220}                className="text-sm"                text="Northwind Freight called to confirm Friday's payment run. The agent confirmed 14 invoices totaling $186,420.00 are scheduled for Friday, October 2, and emailed the remittance preview to their AR inbox."                followUps={[                    "What should the agent have said differently?",                    "Draft a follow-up email to Northwind Freight",                    "Turn this call into an evaluation case",                ]}                onFollowUp={(text) =>                    toast.add({ title: "Sent to Copilot", description: text })                }            />        </section>    );}

Starting on demand

start={false} holds the stream until someone asks for it. onDone fires once when it finishes, here to show where the summary came from.

Handoff from Payments desk agent

after 3:42 on the line
import { StreamingText } from "@oration/canon/components/ai/streaming-text";import { Button } from "@oration/canon/components/button";import * as React from "react";export function StartAndDone() {    const [start, setStart] = React.useState(false);    const [done, setDone] = React.useState(false);    return (        <section            aria-label="AI handoff summary"            className="flex w-full max-w-lg flex-col gap-3 rounded-xl bg-card p-4 text-left shadow-border"        >            <div className="flex items-center justify-between gap-2">                <div className="flex flex-col">                    <h3 className="text-13 font-medium text-foreground">                        Handoff from Payments desk agent                    </h3>                    <span className="text-xs text-muted-foreground">                        after 3:42 on the line                    </span>                </div>                {start ? null : (                    <Button                        type="button"                        variant="outline"                        size="sm"                        onClick={() => setStart(true)}                    >                        Summarize call                    </Button>                )}            </div>            <StreamingText                start={start}                speed={140}                className="text-sm"                onDone={() => setDone(true)}                text="Jordan from Orchard Street wants to switch remittances to a new AP inbox. The agent verified the vendor ID but couldn't confirm the caller, so it transferred instead of changing the address."            />            {done ? (                <p className="text-xs text-muted-foreground">                    Written from 14 turns. Review before you reply.                </p>            ) : null}        </section>    );}

Your own stream

For stop and skip, compose useSimulatedStream, SimpleMarkdown and StreamCaret the way Copilot answers do. Stop freezes the text where it is.

import { SimpleMarkdown } from "@oration/canon/components/ai/markdown";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";import * as React from "react";export function OwnStream() {    const text =        "Containment for the **Remittance questions** agent fell from 78% to 64% on Thursday. The `lookup_remittance` tool started timing out after the ERP maintenance window, and with retries set to 1 the agent transferred on the first failure.";    const stream = useSimulatedStream({ text, speed: 90 });    const [frozen, setFrozen] = React.useState<string | null>(null);    const output = frozen ?? stream.output;    const streaming = frozen === null && !stream.done;    return (        <div className="flex w-full max-w-lg flex-col gap-3">            <SimpleMarkdown                text={output}                streaming={streaming}                trailing={streaming ? <StreamCaret /> : null}            />            <div className="flex items-center gap-2">                {streaming ? (                    <>                        <Button                            type="button"                            variant="outline"                            size="sm"                            onClick={() => setFrozen(stream.output)}                        >                            Stop                        </Button>                        <Button                            type="button"                            variant="ghost"                            size="sm"                            onClick={stream.skip}                        >                            Show all                        </Button>                    </>                ) : (                    <>                        {frozen !== null ? (                            <span className="text-xs text-muted-foreground">                                Stopped                            </span>                        ) : null}                        <Button                            type="button"                            variant="ghost"                            size="sm"                            onClick={() => {                                setFrozen(null);                                stream.restart();                            }}                        >                            <RotateCcwIcon                                data-icon="inline-start"                                aria-hidden="true"                            />                            Regenerate                        </Button>                    </>                )}            </div>        </div>    );}

States#

States
StateTreatment
Waitingstart={false} renders nothing and holds the stream until it flips to true.
StreamingWhole words appear at speed characters per second with the caret at the end. aria-busy is set. Half-typed markers such as ** or [2 are hidden until they close.
DoneThe caret goes, onDone fires once, and follow-ups rise in 40ms apart on spring.moderate.
RegeneratingChange the key to remount; the new text streams from the start.
Text growsIf text changes by appending, the reveal continues from where it was instead of starting over, so it can follow a real stream.
Reduced motionThe whole text renders at once, with no caret, and follow-ups show immediately.

Behavior#

  • Built on useSimulatedStream: one requestAnimationFrame loop reveals text up to the next word boundary, so it reads like tokens, not letters. The default speed is 40 characters per second; the product uses 110 to 420.
  • [n] markers map to citations[n − 1]. With citations it mounts a SourcesProvider, so hovering a marker highlights its card.
  • Source cards render as soon as streaming starts, while showSources is true. Set it to false to place them yourself.
  • onDone fires once per run and text, including immediately under reduced motion.
  • It has no stop or skip control. For those, compose useSimulatedStream, SimpleMarkdown and StreamCaret yourself.
  • Follow-up chips call onFollowUp with their text. The product sends them to Copilot and confirms with a toast.

Do and don't#

Do. Offer two or three follow-ups that are specific to this summary: Draft a follow-up email to Halcyon.

Don't. Offer generic follow-ups such as Tell me more, Explain or Continue. They are noise under every answer.

Do. Keep a summary to two to four sentences, with the next step in bold at the end.

Don't. Stream a long paragraph that restates the ticket. The point of the summary is that nobody has to read the thread.

Content#

  • Lead with who and what: Halcyon's AR team called about invoice INV-20931. Then the facts, with amounts and dates.
  • Bold one or two facts that decide the next step, not whole sentences.
  • End with Next step: and one action when there is one.
  • Put [n] right after the claim it supports, before the period: took the discount [1].
  • Follow-ups are what the person would type next, verb first, under about 40 characters: What's still open?, Draft a follow-up email.
  • Label the block Summary with its source in Slate Meta: from the linked call and thread.

Accessibility#

  • While streaming, the text has aria-busy, and it is not a live region, so screen readers aren't read every word. They read the finished text when they reach it.
  • The caret is aria-hidden.
  • Follow-ups are real buttons in a group named Suggested follow-ups.
  • Under reduced motion the full text appears at once.
  • Citations are named Source 1: Payment PMT-58213, and their previews open on focus.
  • Regenerate is an icon button with the name Regenerate summary and a tooltip.

Design tokens#

Design tokens
TokenUsed for
--foregroundText, bold, and the caret at 55%
--mutedInline code background
--muted-foregroundList markers and the follow-up icon
--animate-caret-blink1.1s ease-out caret blink
spring.moderateFollow-up chips rising in
text-smDefault text size with relaxed leading

API reference#

StreamingText

The streaming block.

Other props spread onto Nothing. Only the props below are read..

Props of StreamingText
PropTypeDefaultDescription
textRequiredstringNo defaultThe full text, in simple markdown.
speednumber40Characters per second, revealed a word at a time.
citationsSource[]No defaultSources for [n] markers, 1-based. Mounts a provider and source cards.
followUpsstring[]No defaultChips shown when the stream finishes.
onFollowUp(text: string) => voidNo defaultCalled with a chip's text.
startbooleantrueFalse holds the stream with nothing rendered.
onDone() => voidNo defaultCalled once when the text is complete.
showSourcesbooleantrueRenders source cards for citations under the text.
sourcesLayout"row" | "grid" | "list""row"Layout of those source cards.
classNamestringNo defaultMerged onto the root, e.g. text-13.

StreamCaret

The caret on its own, for composing a stream with SimpleMarkdown's trailing slot.

Other props spread onto Nothing.

Props of StreamCaret
PropTypeDefaultDescription
classNamestringNo defaultMerged onto the caret.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

Source cards appear as soon as streaming starts, before the markers that point at them. Copilot answers compose the parts by hand to show the cards only once the text is complete.

SimpleMarkdown has no blockquotes, links, italics or tables. Copilot's draft-reply answer quotes the reply with > lines, which render as a literal >.

Headings render as bold paragraphs, not heading elements, so they don't appear in a screen reader's heading list.

There is no stop or skip control, although useSimulatedStream has skip() and restart(). Regenerating means remounting with a new key.

The caret's blink isn't in the global reduced-motion rule. It only disappears because reduced motion renders the text at once.

The default speed of 40 characters per second is slower than every product call site.