Voice orb
The one animated gradient in Canon: it visualizes a live voice agent's state and audio level.
Connecting to Nora
Setting up audio
import { Button } from "@oration/canon/components/button";import { IconAction } from "@oration/canon/components/icon-action";import { toast } from "@oration/canon/components/toast";import { VoiceOrb, type VoiceOrbState } from "@oration/canon/components/voice/orb";import { formatClock } from "@oration/canon/components/voice/waveform";import { MicIcon, MicOffIcon, PhoneIcon, PhoneOffIcon } from "lucide-react";import * as React from "react";export function Hero() { const cycle: VoiceOrbState[] = ["speaking", "listening", "thinking"]; const [phase, setPhase] = React.useState<"connecting" | "live" | "ended">( "connecting", ); const [turn, setTurn] = React.useState(0); const [muted, setMuted] = React.useState(false); const [seconds, setSeconds] = React.useState(0); const agentState = cycle[turn % cycle.length] ?? "speaking"; const state: VoiceOrbState = phase === "connecting" ? "connecting" : phase === "ended" ? "idle" : agentState; React.useEffect(() => { if (phase !== "connecting") return; const id = window.setTimeout(() => setPhase("live"), 1800); return () => window.clearTimeout(id); }, [phase]); React.useEffect(() => { if (phase !== "live") return; const ms = agentState === "speaking" ? 3400 : agentState === "listening" ? 3000 : 1400; const id = window.setTimeout(() => setTurn((n) => n + 1), ms); return () => window.clearTimeout(id); }, [phase, agentState]); React.useEffect(() => { if (phase !== "live") return; const id = window.setInterval(() => setSeconds((s) => s + 1), 1000); return () => window.clearInterval(id); }, [phase]); const status = phase === "connecting" ? "Connecting to Nora" : phase === "ended" ? "Call ended" : agentState === "speaking" ? "Nora is speaking" : agentState === "thinking" ? "Nora is thinking" : muted ? "You're muted" : "Listening"; return ( <div className="flex w-full max-w-xs flex-col overflow-hidden rounded-xl bg-card text-left shadow-border"> <div className="flex items-center gap-2 border-b border-border px-4 py-3"> <div className="flex min-w-0 flex-1 flex-col"> <span className="text-13 font-medium text-foreground"> Test call </span> <span className="text-xs text-muted-foreground"> Payments desk agent </span> </div> </div> <div className="flex flex-col items-center gap-3 px-4 pt-6 pb-4"> <VoiceOrb state={state} level={muted && state === "listening" ? 0.02 : undefined} size={112} label={`Nora, ${status.toLowerCase()}`} /> <div className="flex flex-col items-center gap-0.5 text-center"> <p aria-live="polite" className="text-sm font-medium text-foreground" > {status} </p> <p className="text-xs text-muted-foreground tabular-nums"> {phase === "connecting" ? ( "Setting up audio" ) : ( <> <span className="sr-only">Call time </span> {formatClock(seconds)} </> )} </p> </div> <div className="flex items-center gap-2 pt-1"> {phase === "ended" ? ( <Button type="button" variant="outline" size="sm" onClick={() => { setSeconds(0); setTurn(0); setMuted(false); setPhase("connecting"); }} > <PhoneIcon data-icon="inline-start" aria-hidden="true" /> Call again </Button> ) : ( <> <IconAction label={muted ? "Unmute" : "Mute"} variant="outline" active={muted} disabled={phase !== "live"} onClick={() => setMuted((m) => !m)} > {muted ? ( <MicOffIcon aria-hidden="true" /> ) : ( <MicIcon aria-hidden="true" /> )} </IconAction> <Button type="button" variant="destructive" size="sm" onClick={() => { setPhase("ended"); toast.add({ title: "Call ended", description: `Test call with Nora, ${formatClock(seconds)}.`, }); }} > <PhoneOffIcon data-icon="inline-start" aria-hidden="true" /> End call </Button> </> )} </div> </div> </div> );}Usage#
Voice orb is the live voice agent's signature visual: a soft sphere of cool light whose motion follows the call. It breathes while connecting, leans in with the caller's voice while listening, turns a slow sheen while thinking and swells with the agent's voice while speaking. It is the one animated gradient in Canon, allowed because it shows audio level and call state, not because it is pretty. The thing people get wrong is letting it carry the state alone: it always sits beside a text status such as Nora is speaking or You're muted.
When to use
- At the center of a live call surface: the web call panel, a test call or a call preview.
- In the embeddable widget before a call starts, idle, beside a Start call button.
- Small (about 40px) in the header of a live call card, next to the agent's name and a Live status label.
- Once per surface, for the call that is happening now.
When not to use
- To identify an agent in lists, rows, menus and headers. Use Agent avatar
- For audio level in a compact bar, a minimized call pill or a supervisor monitor. Use Waveform
- For a recording that plays back and seeks. Use Waveform
- For a call's state in a table cell or a queue row. Use Status label
- For AI work that isn't a voice call. Use AI loader
- As decoration in empty states, marketing panels or page headers. Use Illustration
The one animated gradient
The Label-Beside-Color Rule
Anatomy#
- Halo. A radial glow 18% larger than the sphere. Its opacity and scale follow the state: faint while listening, widest while the agent speaks.
- Body. The sphere: a radial gradient lit from the upper left. Pale and cool in light, graphite in dark.
- Light. Three blurred blobs of indigo, blue and lilac that drift inside the sphere. They spread apart as the voice gets louder and draw together while listening.
- Sheen. A conic highlight that fades in and turns only while thinking, so thinking reads differently from speaking.
- Highlight. A fixed specular spot in the upper left that gives the sphere its volume.
- Shade and rim. An inset shadow at the bottom and a 1px rim that keeps the edge visible on white and on dark surfaces.
- Reduced-motion ring. Only under reduced motion: a 1.5px ring that steps outward with the level in thirds. Not drawn in the anatomy above.
Examples#
Call states
Idle, connecting, listening, thinking and speaking, each with the text status that travels with it. Listening and speaking run on a simulated voice because no level is passed.
import { VoiceOrb, type VoiceOrbState } from "@oration/canon/components/voice/orb";export function States() { const states: { state: VoiceOrbState; text: string }[] = [ { state: "idle", text: "Idle" }, { state: "connecting", text: "Connecting to Nora" }, { state: "listening", text: "Listening" }, { state: "thinking", text: "Nora is thinking" }, { state: "speaking", text: "Nora is speaking" }, ]; return ( <div className="grid w-full grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-5"> {states.map((item) => ( <div key={item.state} className="flex flex-col items-center gap-3" > <VoiceOrb state={item.state} size={72} label={`Nora, ${item.text.toLowerCase()}`} /> <span className="text-center text-13 text-foreground"> {item.text} </span> </div> ))} </div> );}Level
Pass level from 0 to 1 when you have real audio: the caller's while listening, the agent's while speaking. Leave it out and the orb simulates a voice with phrases and pauses.
level is left out, so the orb simulates a voice
import { SegmentedControl } from "@oration/canon/components/segmented-control";import { VoiceOrb } from "@oration/canon/components/voice/orb";import * as React from "react";export function Level() { const [state, setState] = React.useState<"listening" | "speaking">( "speaking", ); const [source, setSource] = React.useState<"simulated" | "quiet" | "loud">( "simulated", ); const level = source === "simulated" ? undefined : source === "quiet" ? 0.12 : 0.9; return ( <div className="flex w-full flex-col items-center gap-6"> <VoiceOrb state={state} level={level} size={112} label={`Nora, ${state}`} /> <div className="flex flex-wrap items-center justify-center gap-3"> <SegmentedControl label="Call state" value={state} onValueChange={setState} options={[ { value: "listening", label: "Listening" }, { value: "speaking", label: "Speaking" }, ]} /> <SegmentedControl label="Level source" value={source} onValueChange={setSource} options={[ { value: "simulated", label: "Simulated" }, { value: "quiet", label: "0.12" }, { value: "loud", label: "0.9" }, ]} /> </div> <p className="text-xs text-muted-foreground tabular-nums"> {level === undefined ? "level is left out, so the orb simulates a voice" : `level={${level}}`} </p> </div> );}Sizes
Size is a diameter in pixels. The product uses four: 40 in a call card header, 64 and 88 in the widget, 112 in the call panel. Blur, halo and drift scale with it.
import { VoiceOrb } from "@oration/canon/components/voice/orb";export function Sizes() { const sizes = [ { size: 40, where: "Call card header" }, { size: 64, where: "Compact widget" }, { size: 88, where: "Widget, compact call" }, { size: 112, where: "Call panel" }, ]; return ( <div className="flex flex-wrap items-end justify-center gap-10"> {sizes.map((item) => ( <div key={item.size} className="flex flex-col items-center gap-3" > <VoiceOrb state="listening" size={item.size} label="Nora, listening" /> <span className="flex flex-col items-center text-xs"> <span className="font-medium text-foreground tabular-nums"> {item.size}px </span> <span className="text-muted-foreground"> {item.where} </span> </span> </div> ))} </div> );}In the embeddable widget
Idle before the call, with one line of what to ask and the one filled button. Starting the call moves the orb through connecting to speaking.
Ask about a payment, remittance or W-9.
import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import { VoiceOrb, type VoiceOrbState } from "@oration/canon/components/voice/orb";import { cn } from "@oration/canon/lib/utils";import { MicIcon, PhoneOffIcon } from "lucide-react";import * as React from "react";export function Widget() { const [phase, setPhase] = React.useState<"idle" | "connecting" | "live">( "idle", ); React.useEffect(() => { if (phase !== "connecting") return; const id = window.setTimeout(() => setPhase("live"), 1600); return () => window.clearTimeout(id); }, [phase]); const state: VoiceOrbState = phase === "idle" ? "idle" : phase === "connecting" ? "connecting" : "speaking"; const status = phase === "idle" ? "Ask about a payment, remittance or W-9." : phase === "connecting" ? "Connecting to Nora" : "Nora is speaking"; return ( <div className="flex w-72 flex-col overflow-hidden rounded-xl bg-popover text-left shadow-popover"> <div className="flex flex-col border-b border-border px-4 py-3"> <span className="text-13 font-medium text-foreground"> Cedarline support </span> <span className="text-xs text-muted-foreground"> Usually replies right away </span> </div> <div className="flex h-64 flex-col items-center justify-center gap-4 px-4 text-center"> <VoiceOrb state={state} size={88} label={`Voice agent ${state}`} /> <p aria-live="polite" className={cn( "text-pretty", phase === "idle" ? "text-xs text-muted-foreground" : "text-sm font-medium text-foreground", )} > {status} </p> {phase === "idle" ? ( <Button type="button" size="sm" onClick={() => setPhase("connecting")} > <MicIcon data-icon="inline-start" aria-hidden="true" /> Start call </Button> ) : ( <Button type="button" variant="destructive" size="sm" onClick={() => { setPhase("idle"); toast.add({ title: "Call ended" }); }} > <PhoneOffIcon data-icon="inline-start" aria-hidden="true" /> End call </Button> )} </div> </div> );}In a live call card
Small, at the end of the header row, after the agent avatar and a status label. The label says what the orb shows.
Asking Halcyon for the signed W-9 before Friday's payment run.
import { AgentAvatar } from "@oration/canon/components/agent-avatar";import { StatusLabel } from "@oration/canon/components/status-dot";import { VoiceOrb } from "@oration/canon/components/voice/orb";import * as React from "react";export function CardHeader() { const [speaking, setSpeaking] = React.useState(true); React.useEffect(() => { const id = window.setInterval(() => setSpeaking((s) => !s), 2600); return () => window.clearInterval(id); }, []); return ( <div className="w-full max-w-md 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"> <AgentAvatar agent={{ id: "ag_w9_collection", name: "W-9 collection" }} size={32} state="live" /> <div className="flex min-w-0 flex-1 flex-col"> <span className="truncate text-13 font-medium text-foreground"> W-9 collection </span> <span className="truncate text-xs text-muted-foreground"> Outbound to Halcyon, accounts receivable </span> </div> <StatusLabel tone="primary" className="text-xs text-muted-foreground" > {speaking ? "Speaking" : "Listening"} </StatusLabel> <VoiceOrb state={speaking ? "speaking" : "listening"} size={40} label={speaking ? "Agent speaking" : "Agent listening"} /> </div> <p className="px-4 py-3 text-13 text-pretty text-muted-foreground"> Asking Halcyon for the signed W-9 before Friday's payment run. </p> </div> );}States#
| State | Treatment |
|---|---|
| Idle | Holds still with the light dimmed to 45%. The animation loop stops once the sphere settles. |
| Connecting | Breathes on a 2.6s cycle between 97% and 101% scale, with the halo pulsing and the light at 80%. |
| Listening | Scales up to 5% with the caller's level while the light draws together, like leaning in. |
| Thinking | A steady, slightly faster drift with the conic sheen turning. Level is ignored. |
| Speaking | Swells up to 10% with the agent's level, the light spreads and the halo grows up to 22%. |
| Muted | Not a state of its own: pass listening with a near-silent level such as 0.02 and change the text to You're muted. |
| Offscreen | The loop pauses when the orb scrolls out of view and resumes when it returns. |
| Reduced motion | No loop runs. The light holds its resting position and a ring steps with the level: a third out while connecting, listening or thinking, two thirds while speaking, none when idle. |
Behavior#
- One
requestAnimationFrameloop writes transforms and opacity straight to the DOM, so the orb never re-renders React while it moves. - Leave
levelout while listening or speaking to get a built-in speech envelope: syllable bursts inside phrases, with pauses between. Passlevel(0 to 1) when you have real audio. - Level is smoothed with a fast attack and a slow release, so peaks land at once and the sphere relaxes over about 200ms.
- Changing
statekeeps the current scale and energy and eases toward the new state's targets, so there is no jump between states. - The light's opacity changes over 700ms when entering idle or connecting. The brief exempts the orb from the three springs.
- Each orb picks a random phase on mount, so two orbs on one screen never move in lockstep.
- The halo, blur and drift radius scale with
size, so the orb looks the same at 40px as at 112px. - Light and dark palettes come from private CSS variables on the orb, switched by the
.darkclass. In dark the light blends withmix-blend-screen.
Do and don't#
- On a callPayments desk agentOn a call
- Remittance questionsIdle
- On a callVendor onboardingOn a call
- Payments desk agent
- Remittance questions
- Vendor onboarding
Content#
- Name the agent's persona in the status: Connecting to Nora, Nora is speaking, Nora is thinking.
- Use the caller's point of view for their side: Listening, You're muted.
- For phone tests, say what the line is doing: Calling +1 312 555 0142, Ringing your phone, Answered, connecting the agent.
- Put the call clock under the status in tabular figures (0:42). Before audio is live, say Setting up audio instead.
- Set the orb's own
labelto the persona and the status together: Nora, listening.
Accessibility#
- The orb is
role="img"with anaria-label, Voice agent listening by default. Passlabelwith the persona and state, such as Nora, listening. - Because it is an image, label changes aren't announced. Put the visible status text in an
aria-live="polite"region so state changes are spoken. - The orb is not interactive and takes no focus. Call controls are separate buttons with names and tooltips.
- Under reduced motion nothing moves; a ring steps with the level so the call still looks alive.
- The light is decorative and has no contrast requirement. The status text beside it carries the meaning and must meet text contrast.
Design tokens#
| Token | Used for |
|---|---|
--orb-base-1, --orb-base-2, --orb-base-3 | The sphere's radial gradient, light to shadow |
--orb-a | Indigo light, and the reduced-motion ring |
--orb-b | Blue light |
--orb-c | Lilac light |
--orb-glow | The halo |
--orb-sheen | The thinking sheen |
--orb-highlight | The specular spot |
--orb-shade, --orb-rim | Inset bottom shadow and the 1px rim |
API reference#
VoiceOrb
The orb. Also exported: the VoiceOrbState type.
Other props spread onto Nothing. Only the props below are read..
| Prop | Type | Default | Description |
|---|---|---|---|
stateRequired | "idle" | "connecting" | "listening" | "thinking" | "speaking" | No default | The call state the motion follows. |
level | number | No default | 0 to 1. The caller's level while listening, the agent's while speaking. Leave it out for a simulated voice. Ignored in other states except by the reduced-motion ring. |
size | number | 96 | Diameter in px. The product uses 40, 64, 88 and 112. |
label | string | "Voice agent {state}" | Accessible name. Include the persona and the state. |
className | string | No default | Merged onto the root. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
No product surface passes a real audio level. Every orb runs the simulated speech envelope; the live call view only passes 0.02 while muted.
size is a free number. The product uses 40, 64, 88 and 112px, and the default of 96 matches none of them.
The orb's colors are hard-coded OKLCH values in private variables, not theme tokens. That is deliberate, but a change to the indigo token won't reach the orb.
While animating, level only affects listening and speaking. Under reduced motion the ring reads level in every state except idle, so a thinking orb with a level passed looks louder with motion off than on.