Live transcript
Speaker-labeled transcript rows with interim text, timestamps and flow-node tags.
Web call with Nora
Live- Call started
import { StatusLabel } from "@oration/canon/components/status-dot";import { LiveTranscript, type TranscriptTurn } from "@oration/canon/components/voice/live-transcript";import * as React from "react";export function Hero() { const script: TranscriptTurn[] = [ { id: "s1", speaker: "system", text: "Call started", at: 0 }, { id: "t1", speaker: "agent", text: "Thanks for calling Cedarline accounts payable, this is Nora. How can I help?", at: 1, nodeLabel: "Greeting", metrics: { e2eMs: 620 }, }, { id: "t2", speaker: "user", text: "Hi, I'm calling about invoice OS-4471. We haven't seen the payment yet.", at: 6, }, { id: "t3", speaker: "agent", text: "Thanks, Aisha. OS-4471 was paid by ACH on Friday, September 25. The trace number is 091000019284113.", at: 11, nodeLabel: "Payment status", metrics: { e2eMs: 1310 }, }, { id: "t4", speaker: "user", text: "Great. Can you send the remittance to our AP inbox?", at: 22, }, { id: "t5", speaker: "agent", text: "Done. I've sent it to ap@orchardstreet.example. Is there anything else?", at: 26, nodeLabel: "Send remittance", metrics: { e2eMs: 680 }, }, { id: "t6", speaker: "user", text: "No, that's everything. Thanks, Nora.", at: 33, }, { id: "s2", speaker: "system", text: "Call ended", at: 37 }, ]; const [shown, setShown] = React.useState(1); const [settled, setSettled] = React.useState(true); React.useEffect(() => { if (shown >= script.length && settled) return; const id = window.setTimeout( () => { if (!settled) setSettled(true); else { setShown((n) => n + 1); setSettled(false); } }, settled ? 1200 : 900, ); return () => window.clearTimeout(id); }, [shown, settled, script.length]); const turns = script.slice(0, shown).map((turn, i) => { if (i < shown - 1 || settled || turn.speaker === "system") return turn; const words = turn.text.split(" "); return { ...turn, interim: true, text: words.slice(0, Math.ceil(words.length / 2)).join(" "), metrics: undefined, }; }); const ended = shown >= script.length && settled; return ( <div className="flex h-96 w-full max-w-xl flex-col rounded-xl bg-card text-left shadow-border"> <div className="flex h-11 shrink-0 items-center justify-between border-b border-border px-4"> <h3 className="text-sm font-semibold">Web call with Nora</h3> <StatusLabel tone={ended ? "neutral" : "primary"} pulse={!ended} className="text-xs" > {ended ? "Ended" : "Live"} </StatusLabel> </div> <LiveTranscript turns={turns} agentName="Nora" userName="Aisha Bello" className="min-h-0 flex-1 px-4 pt-3 pb-2" /> </div> );}Usage#
Live transcript shows a voice call as it happens: each turn under its speaker's name, with the flow node that handled it, its latency and a timestamp, plus centered system lines for events such as a transfer. Words still being recognised or spoken render as interim text, muted, italic and shimmering, and settle into ink when final. It keeps the newest turn in view until the reader scrolls up, then offers Jump to latest. It's the transcript tab of a web call with Nora; the common mistake is giving it no bounded height, so it can't scroll.
When to use
- In the transcript tab of a live web call with Nora, beside the call controls.
- When someone testing a flow needs to see which node produced each turn.
- When interim recognition matters, such as checking how Nora hears invoice numbers as a supplier says them.
- For short system events within the call: call started, transferred to accounts payable, call ended.
When not to use
- For a chat with Copilot or an agent that people type into. Use Thread
- For a finished call's recording and transcript on its detail page, with playback. Use Timeline
- For per-turn latency as a chart. Use Latency timeline
- For a single tool call an agent made during the turn. Use Tool chip
- For a customer conversation in the Contact Center inbox. Use Bubble
The Quiet Indigo Rule
The Tabular Figures Rule
The Tint Well Rule
Anatomy#
- NoraPayment statusLatency 640 ms
OS-4471 was paid by ACH on Friday, September 25.
- NoraPayment status
The trace number is 0910 (still speaking)
- Transfer to APTransferred to accounts payable
- Speaker. 12px ink at weight 500:
agentNameoruserName. - Flow-node tag. A 20px Well Gray chip with a workflow icon and the node's name in 11px Slate Meta.
- Latency. The turn's end-to-end latency in 12px tabular Slate Meta: 640 ms.
- Timestamp. Seconds since the call started, as 0:11 or 1:02:15, or a preformatted string.
- Turn text. 14px ink at a relaxed line height. Long words wrap.
- Interim text. Muted, italic and shimmering until final. An agent's interim turn adds a pulsing indigo dot before the name.
- System line. A centered 12px Slate Meta line between hairlines, with an optional node tag and time.
Examples#
Interim text
Words still being recognised or spoken are muted, italic and shimmering. While Nora is speaking, a pulsing dot sits before her name.
- Tomás Ferreira
I need the status of invoice H-8820, that's H as in hotel.
- Nora
Let me check H-8820 for Halcyon (still speaking)
- Tomás Ferreira
Actually it might be H-8802 (still speaking)
import { LiveTranscript } from "@oration/canon/components/voice/live-transcript";export function InterimText() { return ( <div className="h-64 w-full max-w-xl rounded-xl bg-card p-4 text-left shadow-border"> <LiveTranscript agentName="Nora" userName="Tomás Ferreira" className="h-full" turns={[ { id: "i1", speaker: "user", text: "I need the status of invoice H-8820, that's H as in hotel.", at: 4, }, { id: "i2", speaker: "agent", text: "Let me check H-8820 for Halcyon", at: 9, interim: true, }, { id: "i3", speaker: "user", text: "Actually it might be H-8802", at: 12, interim: true, }, ]} /> </div> );}Flow nodes and system lines
Each turn names the node that handled it. System turns render as a centered line for events such as the transfer to AP.
- Call started
- NoraGreeting
Thanks for calling Cedarline accounts payable, this is Nora.
- Wen Zhou
Our invoice WZ-1093 was rejected and I want to dispute it. It's about $7,200.
- NoraInvoice dispute
Disputes over $5,000 go to our AP team. I'll connect you to Priya Raman now.
- Transfer to APTransferred to accounts payable
import { LiveTranscript } from "@oration/canon/components/voice/live-transcript";export function FlowNodes() { return ( <div className="h-80 w-full max-w-xl rounded-xl bg-card p-4 text-left shadow-border"> <LiveTranscript agentName="Nora" userName="Wen Zhou" className="h-full" turns={[ { id: "f1", speaker: "system", text: "Call started", at: 0, }, { id: "f2", speaker: "agent", text: "Thanks for calling Cedarline accounts payable, this is Nora.", at: 1, nodeLabel: "Greeting", }, { id: "f3", speaker: "user", text: "Our invoice WZ-1093 was rejected and I want to dispute it. It's about $7,200.", at: 5, }, { id: "f4", speaker: "agent", text: "Disputes over $5,000 go to our AP team. I'll connect you to Priya Raman now.", at: 12, nodeLabel: "Invoice dispute", }, { id: "f5", speaker: "system", text: "Transferred to accounts payable", at: 18, nodeLabel: "Transfer to AP", }, ]} /> </div> );}Latency and interruptions
metrics.e2eMs adds the turn's latency beside the time, and metrics.interrupted marks a turn the caller cut off.
- NoraIdentify invoiceLatency 710 ms
I can see two open invoices for Northwind Freight. Which one are you calling about?
- Jordan Lee
The one from September.
- NoraPayment statusInterruptedLatency 1,420 ms
That's INV-20931 for $20,625.00, scheduled for
- Jordan Lee
Sorry, can you read the amount again?
import { LiveTranscript } from "@oration/canon/components/voice/live-transcript";export function Metrics() { return ( <div className="h-72 w-full max-w-xl rounded-xl bg-card p-4 text-left shadow-border"> <LiveTranscript agentName="Nora" userName="Jordan Lee" className="h-full" turns={[ { id: "m1", speaker: "agent", text: "I can see two open invoices for Northwind Freight. Which one are you calling about?", at: 14, nodeLabel: "Identify invoice", metrics: { e2eMs: 710 }, }, { id: "m2", speaker: "user", text: "The one from September.", at: 19, }, { id: "m3", speaker: "agent", text: "That's INV-20931 for $20,625.00, scheduled for", at: 21, nodeLabel: "Payment status", metrics: { e2eMs: 1420, interrupted: true }, }, { id: "m4", speaker: "user", text: "Sorry, can you read the amount again?", at: 24, }, ]} /> </div> );}Before anyone speaks
Say when the transcript will start, in the persona's name.
import { LiveTranscript } from "@oration/canon/components/voice/live-transcript";export function Empty() { return ( <div className="h-40 w-full max-w-xl rounded-xl bg-card p-4 text-left shadow-border"> <LiveTranscript agentName="Nora" className="h-full" turns={[]} emptyState="The transcript starts when Nora picks up." /> </div> );}States#
| State | Treatment |
|---|---|
| Empty | A Well Gray well with emptyState, by default The transcript starts when someone speaks. |
| Final turn | Ink text, announced politely to screen readers. |
| Interim turn | Muted, italic, shimmering, with a hidden (still speaking). The list is aria-busy while any turn is interim. |
| Agent speaking | An interim agent turn shows the pulsing indigo dot before the name. |
| Interrupted | metrics.interrupted adds Interrupted in Slate Meta on the header line. |
| Scrolled up | Following stops, and an outline Jump to latest button fades in 8px above the bottom. |
| Focus visible | The scroll region is focusable and draws a 2px Focus Indigo ring at 50%. |
| New turn | Turns that arrive after mount fade up 4px on spring.moderate. Turns present on mount don't animate. |
Behavior#
- Pass the whole list of turns on every update; update a turn in place (same
id) to move it from interim to final. - While the reader is within 24px of the bottom, growth keeps the newest turn in view. Scrolling up stops it; Jump to latest scrolls back smoothly, or jumps under reduced motion.
autoScroll={false}turns following off entirely.- Each turn that becomes final is announced once in a polite live region, as Nora: OS-4471 was paid by ACH on Friday. System lines are announced as their text.
atas a number is seconds since the call started; as a string it's shown as written.- Interim text changes color over 200ms when it settles. The shimmer stops under reduced motion.
- It doesn't size itself. Give it a bounded height such as
h-80, orflex-1 min-h-0inside a flex column.
Do and don't#
- NoraPayment status
OS-4471 was paid by ACH on Friday.
- Noranode_7f3a2c
OS-4471 was paid by ACH on Friday.
- Aisha Bello
Has OS-4471 been paid?
- Nora
Yes, by ACH on Friday.
- Caller
Has OS-4471 been paid?
- Agent
Yes, by ACH on Friday.
Content#
agentNameis the persona, Nora.userNameis the caller's name when known, Aisha Bello, or You in a test call.- Node labels match the canvas exactly, in sentence case: Identify supplier, Payment status, Transfer to AP.
- System lines are short past-tense events: Call started, Transferred to accounts payable, Call ended.
emptyStatesays when the transcript will start: The transcript starts when Nora picks up.- Transcribed text stays as recognised. Don't correct it after the fact; that's what makes the transcript useful for tuning.
Accessibility#
- The scroll region is a focusable
role="region"named Live transcript, so keyboard users can scroll it with the arrow keys. - Final turns are announced through a polite live region with the speaker's name. Interim text isn't announced, and the list is
aria-busywhile any turn is interim. - Interim turns carry a hidden (still speaking) so the state doesn't rely on italics or color.
- Latency has a hidden Latency label before the figure.
- Jump to latest is a text button, reachable with Tab whenever it's shown.
- Under reduced motion the shimmer stops, the jump is instant, and the app's motion config drops the fade-up translate.
| Keys | Action |
|---|---|
| Tab | Focuses the transcript region, then Jump to latest when shown. |
| ↑↓ | Scrolls the focused region. |
| Enter | Activates Jump to latest. |
Design tokens#
| Token | Used for |
|---|---|
--foreground | Speaker names and final text |
--muted-foreground | Interim text, node tags, latency, timestamps, system lines |
--muted | Node tags and the empty well at 70% |
--primary | The agent's speaking dot |
--border | System line hairlines |
--ring | Region focus ring at 50% |
text-shimmer | Interim text |
animate-pulse-soft | Speaking dot, 1.8s |
spring.moderate | New turns fading up |
API reference#
LiveTranscript
The transcript. Also exported: formatAt(at) and the TranscriptTurn and TranscriptSpeaker types.
Other props spread onto Nothing. Only the props below are read..
| Prop | Type | Default | Description |
|---|---|---|---|
turnsRequired | { id: string; speaker: "agent" | "user" | "system"; text: string; interim?: boolean; at?: number | string; nodeLabel?: string; metrics?: { e2eMs?: number; ttfbMs?: number; interrupted?: boolean } }[] | No default | Every turn so far, in order. |
agentName | string | "Agent" | Shown on agent turns. |
userName | string | "Caller" | Shown on user turns. |
autoScroll | boolean | true | Keep the latest turn in view. |
emptyState | React.ReactNode | "The transcript starts when someone speaks." | Shown before the first turn. |
className | string | No default | Merged onto the root. Give it a bounded height. |
formatAt
Formats a turn's time the way the transcript does.
| Prop | Type | Default | Description |
|---|---|---|---|
atRequired | number | string | No default | Seconds, or a preformatted string. |
returns | string | No default | 0:11, 12:04 or 1:02:15. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
metrics.ttfbMs is accepted but never shown.
Timestamps render in <time> without a dateTime attribute.
Only agent interim turns get the speaking dot; a caller's interim turn has no live marker beyond the shimmer.
The registry describes it as two-sided, but turns are one left-aligned column with speaker names, not two sides.
The call detail page in Agent history uses its own transcript panel, not this component, so the two transcripts look and behave differently.
It has its own stick-to-bottom logic (24px threshold) and a text Jump to latest, while Thread uses 48px and an icon-only button.
The region's focus ring is 2px at 50%, not the 3px ring at 40% used elsewhere.