Streaming text
Model output that streams in with a caret, citations and follow-up chips.
Summary
from the linked call and threadSources2
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
followUpswhen there are two or three obvious next questions. - As the model for your own stream:
StreamCaretwithSimpleMarkdownanduseSimulatedStream.
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
The One Filled Button Rule
The Thirteen-Fourteen Rule
className="text-13".Anatomy#
PMT-58213 went out on September 25 with the early-pay
- 1ERP paymentPayment PMT-58213ACH, $20,212.50, sent Sep 25.
- Text. Simple markdown at 14px with relaxed leading: paragraphs,
#headings as bold lines, bullet and numbered lists, bold andcode. - Citation. Each
[n]becomes a marker forcitations[n − 1], with a hover preview. - 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.
- Sources. Source cards for
citations, in a row by default. - 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
- 1Payment security95% matchBank change verificationBank changes need a verified callback to the number on file before the change is applied.
- 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 lineimport { 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#
| State | Treatment |
|---|---|
| Waiting | start={false} renders nothing and holds the stream until it flips to true. |
| Streaming | Whole 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. |
| Done | The caret goes, onDone fires once, and follow-ups rise in 40ms apart on spring.moderate. |
| Regenerating | Change the key to remount; the new text streams from the start. |
| Text grows | If text changes by appending, the reveal continues from where it was instead of starting over, so it can follow a real stream. |
| Reduced motion | The whole text renders at once, with no caret, and follow-ups show immediately. |
Behavior#
- Built on
useSimulatedStream: onerequestAnimationFrameloop 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 tocitations[n − 1]. With citations it mounts aSourcesProvider, so hovering a marker highlights its card.- Source cards render as soon as streaming starts, while
showSourcesis true. Set it to false to place them yourself. onDonefires once per run and text, including immediately under reduced motion.- It has no stop or skip control. For those, compose
useSimulatedStream,SimpleMarkdownandStreamCaretyourself. - Follow-up chips call
onFollowUpwith their text. The product sends them to Copilot and confirms with a toast.
Do and don't#
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#
| Token | Used for |
|---|---|
--foreground | Text, bold, and the caret at 55% |
--muted | Inline code background |
--muted-foreground | List markers and the follow-up icon |
--animate-caret-blink | 1.1s ease-out caret blink |
spring.moderate | Follow-up chips rising in |
text-sm | Default text size with relaxed leading |
API reference#
StreamingText
The streaming block.
Other props spread onto Nothing. Only the props below are read..
| Prop | Type | Default | Description |
|---|---|---|---|
textRequired | string | No default | The full text, in simple markdown. |
speed | number | 40 | Characters per second, revealed a word at a time. |
citations | Source[] | No default | Sources for [n] markers, 1-based. Mounts a provider and source cards. |
followUps | string[] | No default | Chips shown when the stream finishes. |
onFollowUp | (text: string) => void | No default | Called with a chip's text. |
start | boolean | true | False holds the stream with nothing rendered. |
onDone | () => void | No default | Called once when the text is complete. |
showSources | boolean | true | Renders source cards for citations under the text. |
sourcesLayout | "row" | "grid" | "list" | "row" | Layout of those source cards. |
className | string | No default | Merged 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.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Merged 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.