Skip to content

Voice orb

The one animated gradient in Canon: it visualizes a live voice agent's state and audio level.

Status
Beta
Level
Atom
Category
Voice
Adoption
Not used yet
import { VoiceOrb } from "@oration/canon/components/voice/orb";
packages/canon/src/components/voice/orb.tsx
Test callPayments desk agent

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 orb is the only place Canon allows a soft animated gradient, because it visualizes audio level and call state. Don't reuse its palette, its blur or its motion for chrome, backgrounds or loaders.

The Label-Beside-Color Rule

The orb never carries state alone. A text status that changes with it (Connecting to Nora, Listening, Nora is thinking) sits right below or beside it, so the state reads without motion and in grayscale.

Anatomy#

  1. Halo. A radial glow 18% larger than the sphere. Its opacity and scale follow the state: faint while listening, widest while the agent speaks.
  2. Body. The sphere: a radial gradient lit from the upper left. Pale and cool in light, graphite in dark.
  3. 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.
  4. Sheen. A conic highlight that fades in and turns only while thinking, so thinking reads differently from speaking.
  5. Highlight. A fixed specular spot in the upper left that gives the sphere its volume.
  6. Shade and rim. An inset shadow at the bottom and a 1px rim that keeps the edge visible on white and on dark surfaces.
  7. 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.

Idle
Connecting to Nora
Listening
Nora is thinking
Nora is speaking
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.

40pxCall card header
64pxCompact widget
88pxWidget, compact call
112pxCall panel
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.

Cedarline supportUsually replies right away

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.

On a call
W-9 collectionOutbound to Halcyon, accounts receivable
Speaking

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#

States
StateTreatment
IdleHolds still with the light dimmed to 45%. The animation loop stops once the sphere settles.
ConnectingBreathes on a 2.6s cycle between 97% and 101% scale, with the halo pulsing and the light at 80%.
ListeningScales up to 5% with the caller's level while the light draws together, like leaning in.
ThinkingA steady, slightly faster drift with the conic sheen turning. Level is ignored.
SpeakingSwells up to 10% with the agent's level, the light spreads and the halo grows up to 22%.
MutedNot a state of its own: pass listening with a near-silent level such as 0.02 and change the text to You're muted.
OffscreenThe loop pauses when the orb scrolls out of view and resumes when it returns.
Reduced motionNo 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 requestAnimationFrame loop writes transforms and opacity straight to the DOM, so the orb never re-renders React while it moves.
  • Leave level out while listening or speaking to get a built-in speech envelope: syllable bursts inside phrases, with pauses between. Pass level (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 state keeps 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 .dark class. In dark the light blends with mix-blend-screen.

Do and don't#

Listening1:12
Do. Pair the orb with a text status that changes with it, and the call clock below.
Don't. Show the orb alone and expect people to read listening from speaking by motion.
  • On a callPayments desk agentOn a call
  • Remittance questionsIdle
  • On a callVendor onboardingOn a call
Do. Use agent avatars to identify agents in a list, with a status label for who is on a call.
  • Payments desk agent
  • Remittance questions
  • Vendor onboarding
Don't. Put an orb in every row. It is the live call visual, not an identity mark, and a column of animated gradients is noise.
Do. Keep Start call, Mute and End call as real buttons beside the orb.
Don't. Make the orb itself the button. It has no focus style, no name for the action and no pressed state.

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 label to the persona and the status together: Nora, listening.

Accessibility#

  • The orb is role="img" with an aria-label, Voice agent listening by default. Pass label with 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#

Design tokens
TokenUsed for
--orb-base-1, --orb-base-2, --orb-base-3The sphere's radial gradient, light to shadow
--orb-aIndigo light, and the reduced-motion ring
--orb-bBlue light
--orb-cLilac light
--orb-glowThe halo
--orb-sheenThe thinking sheen
--orb-highlightThe specular spot
--orb-shade, --orb-rimInset 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..

Props of VoiceOrb
PropTypeDefaultDescription
stateRequired"idle" | "connecting" | "listening" | "thinking" | "speaking"No defaultThe call state the motion follows.
levelnumberNo default0 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.
sizenumber96Diameter in px. The product uses 40, 64, 88 and 112.
labelstring"Voice agent {state}"Accessible name. Include the persona and the state.
classNamestringNo defaultMerged 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.