Live and running states
Calls, runs and enrichment that change while you watch, and how Canon shows work in progress.
The problem#
A lot of Oration changes while people watch: workflow runs step through nodes, enrichment columns fill row by row, Nora talks to a supplier, the Copilot writes an answer, teammates go on and off calls.
Each of these needs to say “this is happening now” without turning the screen into a light show. Spinners everywhere, colored rows and pulsing badges compete with each other and with the one decision the view exists for. Canon gives live work one vocabulary: indigo with a label for what's running, a shimmer for what's being worked out, ink for what's done, and motion only where it carries information and only when people allow it.
The solution#
Every live thing is drawn from the same few treatments.
| State | Drawn as | Motion when allowed | Under reduced motion |
|---|---|---|---|
| Running node or run | A 2px indigo ring at 60% on the node, a spinner and Running in indigo. Runs in a list: a status label in the live tone. | Spinner turns; the status dot pulses; the active edge marches (0.9s). | Spinner, pulse and march stop. Ring and label stay. |
| Queued | Queued in Slate Meta. In a cell, an 8px skeleton bar whose width varies by row. | The bar shimmers (1.6s). | The bar holds still. |
| Being worked out | The label in a moving shimmer: Finding email…, Researching…, Thinking… | Shimmer sweeps the text. | Plain Slate Meta text. |
| Done | The value as plain content. Passed nodes get a 1px green ring at 50% and a check. | None. | None. |
| Not found or skipped | No result in Slate Meta. Skipped nodes fade to 60%. | None. | None. |
| Failed | A red icon with its message. Failed nodes get a 2px red ring at 60%. | None. | None. |
| Live call | The voice orb, a waveform and a live transcript with interim text. | The orb drifts and swells with the level; bars move. | The orb holds a static level ring; bars hold a static shape. |
| Streaming text | Text arriving behind a soft caret. | Caret blinks (1.1s). | The full text renders at once. |
| Presence | A badge on the avatar and a status label beside the name. | On a call pulses. | No pulse. |
The Quiet Indigo Rule
The Label-Beside-Color Rule
The Three Springs Rule
spring.fast, spring.moderate and spring.slow, with exits one tier faster that never bounce. No other timings exist except the skeleton reveal, shimmer, the voice orb and meter fills, and keyboard-driven surfaces don't animate.Running workflows#
Indigo is spent on live state only when a text label travels with it. A running node takes the indigo ring, spinner and Running; the edge leading into it turns into a dashed indigo line that marches.
A test run stepping through nodes
Run test steps through the invoice intake workflow. Each node is queued, running, then passed.
- Trigger
Invoice received
From the AP inbox or the supplier portal
- Match
Match to purchase order
Three-way match within 2% tolerance
- Approval
Route for approval
Over $25,000 goes to Maya Okafor
- Notify
Tell the supplier
Email remittance advice when paid
import { Button } from "@oration/canon/components/button";import { StatusLabel } from "@oration/canon/components/status-dot";import { cn } from "@oration/canon/lib/utils";import { BellIcon, CheckIcon, FileInputIcon, GitMergeIcon, LoaderCircleIcon, UserCheckIcon, XIcon,} from "lucide-react";import * as React from "react";export function WorkflowRun() { type Run = "idle" | "queued" | "running" | "success" | "error"; const nodes = [ { id: "n1", icon: FileInputIcon, category: "Trigger", title: "Invoice received", description: "From the AP inbox or the supplier portal", }, { id: "n2", icon: GitMergeIcon, category: "Match", title: "Match to purchase order", description: "Three-way match within 2% tolerance", }, { id: "n3", icon: UserCheckIcon, category: "Approval", title: "Route for approval", description: "Over $25,000 goes to Maya Okafor", }, { id: "n4", icon: BellIcon, category: "Notify", title: "Tell the supplier", description: "Email remittance advice when paid", }, ]; const [step, setStep] = React.useState(-1); const end = nodes.length * 2; const active = step >= 0 && step < end; React.useEffect(() => { if (!active) return; const id = window.setTimeout(() => setStep((s) => s + 1), 700); return () => window.clearTimeout(id); }, [active]); const runOf = (index: number): Run => { if (step < 0) return "idle"; if (step < index * 2) return "queued"; if (step === index * 2) return "running"; return "success"; }; const ring: Record<Run, string> = { idle: "", queued: "", running: "ring-2 ring-primary/60", success: "ring-1 ring-success/50", error: "ring-2 ring-destructive/60", }; return ( <div className="flex w-full max-w-sm flex-col gap-4 text-left"> <div className="flex items-center justify-between gap-3"> {active ? ( <StatusLabel tone="primary" pulse> Running </StatusLabel> ) : step >= end ? ( <StatusLabel tone="success">Passed</StatusLabel> ) : ( <StatusLabel>Not run</StatusLabel> )} <Button size="sm" disabled={active} onClick={() => setStep(0)} variant="outline" > {step >= end ? "Run again" : "Run test"} </Button> </div> <ol aria-label="Invoice intake workflow" className="flex flex-col"> {nodes.map((node, index) => { const run = runOf(index); const Icon = node.icon; const edgeLive = runOf(index + 1) === "running"; return ( <li key={node.id} className="flex flex-col items-center" > <div data-run={run} className={cn( "w-full rounded-xl bg-card shadow-border transition-[box-shadow,opacity] duration-200 ease-out", ring[run], )} > <div className="flex items-start gap-2.5 p-3"> <span aria-hidden="true" className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground" > <Icon className="size-4" /> </span> <div className="min-w-0 flex-1"> <div className="flex items-center gap-2"> <span className="text-xs text-muted-foreground"> {node.category} </span> {run === "running" ? ( <span className="ml-auto inline-flex items-center gap-1 text-xs font-medium text-primary"> <LoaderCircleIcon aria-hidden="true" className="size-3 motion-safe:animate-spin" /> Running </span> ) : run === "success" ? ( <span className="ml-auto inline-flex items-center gap-1 text-xs font-medium text-success"> <CheckIcon aria-hidden="true" className="size-3" /> Passed </span> ) : run === "error" ? ( <span className="ml-auto inline-flex items-center gap-1 text-xs font-medium text-destructive"> <XIcon aria-hidden="true" className="size-3" /> Failed </span> ) : run === "queued" ? ( <span className="ml-auto text-xs font-medium text-muted-foreground"> Queued </span> ) : null} </div> <p className="truncate text-13 font-medium text-foreground"> {node.title} </p> <p className="mt-0.5 text-xs text-muted-foreground"> {node.description} </p> </div> </div> </div> {index < nodes.length - 1 ? ( <svg aria-hidden="true" width="2" height="24" viewBox="0 0 2 24" className="overflow-visible" > <line x1="1" y1="0" x2="1" y2="24" stroke={ edgeLive ? "var(--primary)" : "var(--border-strong)" } strokeWidth={edgeLive ? 1.75 : 1.5} strokeDasharray={ edgeLive ? "5 5" : undefined } className={ edgeLive ? "motion-safe:animate-dash" : undefined } /> </svg> ) : null} </li> ); })} </ol> <span role="status" className="sr-only"> {step >= end ? "Test run passed" : ""} </span> </div> );}- The running node gets
ring-2 ring-primary/60and an indigo Running badge with a spinningLoaderCircle; Flow draws both fromrun. - Passed nodes keep a 1px green ring at 50% and a check so the path the run took stays readable.
- The active edge is 1.75px, dashed 5 5, in indigo, marching with
animate-dash(0.9s linear). Other edges stay 1.5px Firm Hairline. - The run's own status sits in the header as a status label in the live tone, pulsing while it runs.
Enrichment cells#
Canon's signature live interaction: a column runs, and its cells resolve row by row while the credit balance counts down.
A column resolving row by row
Find AP contacts queues every row, then resolves two at a time: found, no result, or an error. Each email found costs 2 credits.
| Supplier | AP contact |
|---|---|
| Northwind Freight | |
| Halcyon Packaging | |
| Orchard Street Foods | |
| Pinecrest Supply | |
| Bayline Logistics | |
| Granite Peak Office |
import { AnimatedNumber } from "@oration/canon/components/animated-number";import { Button } from "@oration/canon/components/button";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { cn } from "@oration/canon/lib/utils";import { CircleAlertIcon, RotateCcwIcon } from "lucide-react";import * as React from "react";export function EnrichmentColumn() { type Result = | { kind: "found"; value: string } | { kind: "none" } | { kind: "error"; message: string }; const rows: { id: string; name: string; color: "blue" | "teal" | "orange" | "violet" | "green" | "pink"; result: Result; }[] = [ { id: "s1", name: "Northwind Freight", color: "blue", result: { kind: "found", value: "ap@northwindfreight.com" }, }, { id: "s2", name: "Halcyon Packaging", color: "teal", result: { kind: "found", value: "ar@halcyonpack.com" }, }, { id: "s3", name: "Orchard Street Foods", color: "orange", result: { kind: "none" }, }, { id: "s4", name: "Pinecrest Supply", color: "violet", result: { kind: "found", value: "billing@pinecrest.co" }, }, { id: "s5", name: "Bayline Logistics", color: "green", result: { kind: "error", message: "Provider timed out" }, }, { id: "s6", name: "Granite Peak Office", color: "pink", result: { kind: "found", value: "accounts@granitepeak.com" }, }, ]; const widths = ["w-3/5", "w-2/5", "w-1/2", "w-2/3", "w-1/3"]; const COST = 2; const [tick, setTick] = React.useState(-1); const end = rows.length + 2; const running = tick >= 0 && tick < end; React.useEffect(() => { if (!running) return; const id = window.setTimeout(() => setTick((t) => t + 1), 650); return () => window.clearTimeout(id); }, [running]); const stateOf = (index: number) => { if (tick < 0) return "idle" as const; if (tick < index + 1) return "queued" as const; if (tick < index + 3) return "running" as const; return "done" as const; }; const resolved = rows.filter((_, index) => stateOf(index) === "done"); const found = resolved.filter((row) => row.result.kind === "found").length; const credits = 1240 - found * COST; const finished = tick >= end; return ( <div className="w-full max-w-2xl overflow-hidden rounded-xl bg-card text-left shadow-border"> <div className="flex items-center justify-between gap-3 border-b border-border px-3 py-2"> <span className="text-13 font-medium text-foreground"> Suppliers </span> <div className="flex items-center gap-2"> <Button variant="ghost" size="sm" disabled={tick < 0 || running} onClick={() => setTick(-1)} > <RotateCcwIcon data-icon="inline-start" aria-hidden="true" /> Reset </Button> <Button size="sm" disabled={running} onClick={() => setTick(0)} > Find AP contacts </Button> </div> </div> <table className="w-full table-fixed border-collapse text-13"> <caption className="sr-only"> Suppliers and their AP contacts </caption> <thead> <tr className="h-8 border-b border-border text-left text-xs font-medium text-muted-foreground"> <th scope="col" className="w-[42%] px-3 font-medium"> Supplier </th> <th scope="col" className="px-3 font-medium"> AP contact </th> </tr> </thead> <tbody aria-busy={running || undefined}> {rows.map((row, index) => { const state = stateOf(index); return ( <tr key={row.id} className="h-9 border-b border-border" > <td className="px-3"> <span className="flex min-w-0 items-center gap-2"> <MonogramTile name={row.name} color={row.color} size="sm" /> <span className="truncate font-medium text-foreground"> {row.name} </span> </span> </td> <td className="px-3"> <span className="flex min-w-0 items-center"> {state === "idle" ? ( <span aria-hidden="true" className="text-subtle-foreground" > – </span> ) : state === "queued" ? ( <> <span aria-hidden="true" className={cn( "skeleton-shimmer h-2 rounded-full", widths[ index % widths.length ], )} /> <span className="sr-only"> Queued </span> </> ) : state === "running" ? ( <span className="truncate text-shimmer"> Finding email… </span> ) : row.result.kind === "found" ? ( <span className="truncate text-foreground"> {row.result.value} </span> ) : row.result.kind === "none" ? ( <span className="truncate text-muted-foreground"> No result </span> ) : ( <span className="flex min-w-0 items-center gap-1.5 text-destructive"> <CircleAlertIcon aria-hidden="true" className="size-3.5 shrink-0" /> <span className="truncate"> {row.result.message} </span> </span> )} </span> </td> </tr> ); })} </tbody> </table> <div className="flex h-9 items-center justify-between gap-3 bg-background px-3 text-xs text-muted-foreground"> <span className="tabular-nums"> {resolved.length} of {rows.length} resolved </span> <span className="flex items-center gap-1.5"> Credits <AnimatedNumber value={credits} className="font-medium text-foreground" /> </span> </div> <span role="status" className="sr-only"> {finished ? `AP contacts finished: ${found} found, ${resolved.length - found} without an email.` : ""} </span> </div> );}| Cell | Treatment |
|---|---|
| Not run | An en dash in Faint Slate, hidden from screen readers. |
| Queued | An 8px skeleton-shimmer bar with round ends. Widths rotate through 60, 40, 50, 67 and 33% by row so the column doesn't read as one block. |
| Running | The column's verb in text-shimmer: Finding… for enrichment, Searching… for waterfalls, Researching… for AI columns, Calculating… for formulas. |
| Found | The value as plain content, in the column's display. |
| Not found | No result in Slate Meta. Never blank, never N/A. |
| Error | A 14px red CircleAlert and the message: Provider timed out. |
| Credits | An animated number in tabular figures that rolls down as cells resolve. |
Skeleton pulses, enrichment shimmers
The Skeleton component draws animate-pulse, not the shimmer, so the grid builds queued cells from the skeleton-shimmer utility directly, as the example does. The cell states live in the tables grid rather than in Data grid.
Live calls#
A call is the one place a soft animated gradient is allowed. The orb maps call state to motion; the waveform shows the level; the transcript shows the words as they're recognized.
Nora on a test call
Start the call. Interim words arrive muted, italic and shimmering, then settle. The orb connects, listens, thinks and speaks; End call stops it.
import { Button } from "@oration/canon/components/button";import { StatusLabel } from "@oration/canon/components/status-dot";import { toast } from "@oration/canon/components/toast";import { LiveTranscript, type TranscriptTurn } from "@oration/canon/components/voice/live-transcript";import { VoiceOrb, type VoiceOrbState } from "@oration/canon/components/voice/orb";import { formatClock, Waveform } from "@oration/canon/components/voice/waveform";import * as React from "react";export function LiveCall() { const script: Omit<TranscriptTurn, "id" | "at">[] = [ { speaker: "agent", text: "Hi, this is Nora from Cedarline supplier support. Who am I speaking with?", }, { speaker: "user", text: "Hi Nora, it's Dana from Northwind Freight accounts receivable.", }, { speaker: "agent", text: "Thanks, Dana. Which invoice are you calling about?", }, { speaker: "user", text: "INV-20417. It was due on the second." }, { speaker: "system", text: "Looked up INV-20417", nodeLabel: "Payment lookup", }, { speaker: "agent", text: "INV-20417 is in Thursday's payment run for $18,240.00. Remittance advice reaches your inbox that morning.", }, ]; const [phase, setPhase] = React.useState(-1); const [ended, setEnded] = React.useState(false); const [seconds, setSeconds] = React.useState(0); const end = script.length * 2; const live = phase >= 0 && phase < end && !ended; const done = ended || phase >= end; React.useEffect(() => { if (!live) return; const id = window.setTimeout(() => setPhase((p) => p + 1), 1100); return () => window.clearTimeout(id); }, [live]); React.useEffect(() => { if (!live) return; const id = window.setInterval(() => setSeconds((s) => s + 1), 1000); return () => window.clearInterval(id); }, [live]); const index = Math.min(Math.floor(phase / 2), script.length - 1); const interim = live && phase % 2 === 0; const shown = ended && phase % 2 === 0 ? index : index + 1; const turns: TranscriptTurn[] = phase < 0 ? [] : script.slice(0, shown).map((turn, i) => { const isLast = i === index; const partial = isLast && interim && turn.speaker !== "system" ? turn.text .split(" ") .slice( 0, Math.ceil( turn.text.split(" ").length * 0.6, ), ) .join(" ") : turn.text; return { ...turn, id: `t${i}`, at: i * 4 + 1, text: partial, interim: isLast && interim && turn.speaker !== "system", }; }); const last = script[index]; let orb: VoiceOrbState = "idle"; if (live && phase === 0) orb = "connecting"; else if (live && last) { if (last.speaker === "agent") orb = interim ? "speaking" : "listening"; else if (last.speaker === "user") orb = interim ? "listening" : "thinking"; else orb = "thinking"; } return ( <div className="flex w-full max-w-lg flex-col overflow-hidden rounded-xl bg-card text-left shadow-border"> <div className="flex items-center gap-3 border-b border-border px-4 py-3"> <VoiceOrb state={orb} size={44} label={`Nora is ${orb}`} /> <div className="flex min-w-0 flex-1 flex-col gap-0.5"> <span className="text-sm font-semibold text-foreground"> Nora with Northwind Freight </span> <span className="flex items-center gap-3 text-xs text-muted-foreground"> {live ? ( <StatusLabel tone="primary" pulse className="text-xs" > On a call </StatusLabel> ) : ( <StatusLabel className="text-xs"> {done ? "Call ended" : "Ready"} </StatusLabel> )} <span className="tabular-nums"> {formatClock(seconds)} </span> </span> </div> <Waveform active={orb === "speaking" || orb === "listening"} tone={orb === "listening" ? "muted" : "ink"} bars={16} height={20} /> </div> <LiveTranscript turns={turns} agentName="Nora" userName="Dana, Northwind Freight" emptyState="Start the call to see the transcript arrive." className="h-64 px-4 py-3" /> <div className="flex justify-end gap-2 border-t border-border px-4 py-2.5"> {live ? ( <Button variant="destructive" size="sm" onClick={() => { setEnded(true); toast.add({ title: "Call ended", description: "Nora saved the transcript to Northwind Freight.", }); }} > End call </Button> ) : ( <Button size="sm" onClick={() => { setSeconds(0); setEnded(false); setPhase(0); }} > {done ? "Call again" : "Start test call"} </Button> )} </div> </div> );}- The orb has five states: idle, connecting, listening, thinking and speaking. It pauses offscreen and stops when idle.
- The waveform is ink while Nora speaks and Slate Meta while she listens; the call status label is the live tone with On a call and a tabular clock.
- Agent turns that are still being spoken carry a small indigo dot that pulses softly beside the name.
- Flow node tags and tool calls appear as system lines between turns, so a test call shows the path it took.
Streaming text#
Model output arrives with a soft caret and settles into plain text. See AI assistance for sources and follow-ups.
A call summary streaming in
Call summary
import { StreamingText } from "@oration/canon/components/ai/streaming-text";import { Button } from "@oration/canon/components/button";import { MailIcon, RotateCcwIcon } from "lucide-react";import * as React from "react";export function StreamingSummary() { const [run, setRun] = React.useState(0); return ( <section aria-labelledby="live-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"> <MailIcon aria-hidden="true" className="size-4 text-muted-foreground" /> <h3 id="live-summary-title" className="text-13 font-medium text-foreground" > Call summary </h3> <Button variant="ghost" size="xs" className="ml-auto text-muted-foreground" onClick={() => setRun((current) => current + 1)} > <RotateCcwIcon data-icon="inline-start" aria-hidden="true" /> Replay </Button> </header> <StreamingText key={run} speed={220} text="Dana from Northwind Freight asked about **INV-20417**. Nora confirmed it pays in Thursday's run for $18,240.00 and that remittance advice arrives by email that morning. No follow-up needed." className="text-sm leading-relaxed text-pretty" /> </section> );}Presence#
Who is available, on a call or away, in the Contact Center queue and on every assignee picker. A badge on the avatar for a glance, a label beside the name for certainty.
The support team
Tomás's call clock ticks in tabular figures, and only the live state pulses.
- PRPriya RamanAvailable
- TFTomás FerreiraOn a call4:12
- JLJordan LeeWrapping up
- ABAisha BelloAway
import { Avatar, AvatarBadge, AvatarFallback } from "@oration/canon/components/avatar";import { StatusLabel } from "@oration/canon/components/status-dot";import { formatClock } from "@oration/canon/components/voice/waveform";import * as React from "react";export function Presence() { const [seconds, setSeconds] = React.useState(252); React.useEffect(() => { const id = window.setInterval(() => setSeconds((s) => s + 1), 1000); return () => window.clearInterval(id); }, []); const people = [ { name: "Priya Raman", initials: "PR", tone: "success" as const, badge: "bg-success", status: "Available", }, { name: "Tomás Ferreira", initials: "TF", tone: "primary" as const, badge: "bg-primary", status: "On a call", live: true, }, { name: "Jordan Lee", initials: "JL", tone: "warning" as const, badge: "bg-warning", status: "Wrapping up", }, { name: "Aisha Bello", initials: "AB", tone: "neutral" as const, badge: "bg-subtle-foreground", status: "Away", }, ]; return ( <ul aria-label="Support team" className="w-full max-w-sm overflow-hidden rounded-xl bg-card text-left shadow-border" > {people.map((person) => ( <li key={person.name} className="flex h-12 items-center gap-3 border-b border-border px-4 last:border-b-0" > <Avatar> <AvatarFallback className="text-xs"> {person.initials} </AvatarFallback> <AvatarBadge className={person.badge} /> </Avatar> <span className="min-w-0 flex-1 truncate text-13 font-medium text-foreground"> {person.name} </span> <StatusLabel tone={person.tone} pulse={person.live}> {person.status} </StatusLabel> {person.live ? ( <span className="w-10 text-right text-xs text-muted-foreground tabular-nums"> {formatClock(seconds)} </span> ) : ( <span aria-hidden="true" className="w-10" /> )} </li> ))} </ul> );}Motion and reduced motion#
Live motion is the exception to the Three Springs Rule: shimmer, the orb, the waveform, pulses and meter fills run on their own timings. All of them stop or settle for people who ask for less motion.
| Token | Value |
|---|---|
| --animate-shimmer | shimmer 1.6s linear infinite |
| --animate-pulse-soft | pulse-soft 1.8s var(--ease-in-out) infinite |
| --animate-dash | dash 0.9s linear infinite |
| --animate-caret-blink | caret-blink 1.1s ease-out infinite |
The reduced-motion rule#
Shimmer stops in CSS, along with scale, translate and blur on every enter and exit. Components built on Motion read useReducedMotion for the rest.
@media (prefers-reduced-motion: reduce) { [data-slot="illustration"] * { /* biome-ignore lint/complexity/noImportantStyles: a drawing at rest is its final pose */ animation: none !important; }}- Pulse only with
motion-safe:.StatusDotwithpulseusesmotion-safe:animate-ping; live transcript dots usemotion-safe:animate-pulse-soft. - Spinners on live labels use
motion-safe:animate-spinand stay as a still glyph otherwise. - The orb, the waveform and streaming text read
useReducedMotionand draw a static frame: a level ring, a stepped bar shape, the full text. - Never make a live state depend on its motion. With every animation off, the label still says what's happening.
Never color alone#
Indigo, green and red say a lot to some people and nothing to others. Every live state carries a word.
Accessibility#
Live regions should tell people what changed, at a pace they can follow.
- Mark regions that are filling with
aria-busy(the table body during a run, streaming text until it finishes). - Announce outcomes, not every step: one polite status when a column finishes (AP contacts finished: 4 found, 2 without an email), not one per cell.
- The live transcript announces final turns politely and marks interim text as still speaking for screen readers.
- Queued skeleton bars are
aria-hiddenwith a screen-reader Queued; an en dash for not run is hidden entirely. - Clocks and counters are tabular so their width doesn't jitter, and they aren't live regions.
- The orb takes a label that names the state (Nora is speaking); the waveform is decorative unless given a label.
Components#
The parts this pattern is built from.
- Status labelAn 8px dot that always travels with a text label, in neutral, live, success, warning, danger and info tones.
- Flow canvasThe React Flow canvas: dotted grid, node cards, smooth-step edges and run states.
- SkeletonShimmering placeholders shaped like the content that is loading, with presets for text, rows, tables, cards, stats and charts.
- Animated numberA tabular figure that rolls to its new value when it changes.
- Voice orbThe one animated gradient in Canon: it visualizes a live voice agent's state and audio level.
- WaveformLive level bars and a static, seekable waveform for recordings.
- Live transcriptSpeaker-labeled transcript rows with interim text, timestamps and flow-node tags.
- Streaming textModel output that streams in with a caret, citations and follow-up chips.
- AvatarA person's photo or initials in a circle, alone or in a group.