Waveform
Live level bars and a static, seekable waveform for recordings.
Call recording
Northwind Freight, Thursday, Sep 24import { Button } from "@oration/canon/components/button";import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger,} from "@oration/canon/components/dropdown-menu";import { IconAction } from "@oration/canon/components/icon-action";import { toast } from "@oration/canon/components/toast";import { formatClock, StaticWaveform } from "@oration/canon/components/voice/waveform";import { DownloadIcon, PauseIcon, PlayIcon, RotateCcwIcon, RotateCwIcon,} from "lucide-react";import * as React from "react";export function Hero() { const duration = 425; const peaks = React.useMemo( () => Array.from({ length: 160 }, (_, i) => { const pause = i % 20 > 16; const caller = Math.floor(i / 20) % 2 === 1; const shape = 0.55 + 0.45 * Math.abs(Math.sin(i * 1.7) * Math.cos(i * 0.43)); // Round peaks: long decimals in inline % heights break hydration. return pause ? 0.06 : Math.round((caller ? 0.66 : 0.52) * shape * 100) / 100; }), [], ); const [progress, setProgress] = React.useState(0.31); const [playing, setPlaying] = React.useState(false); const [rate, setRate] = React.useState(1); React.useEffect(() => { if (!playing) return; const id = window.setInterval(() => { setProgress((p) => { const next = p + (0.25 * rate) / duration; if (next >= 1) { setPlaying(false); return 1; } return next; }); }, 250); return () => window.clearInterval(id); }, [playing, rate]); const seekBy = (seconds: number) => setProgress((p) => Math.min(1, Math.max(0, p + seconds / duration))); return ( <div className="flex w-full max-w-2xl flex-col gap-3 rounded-xl bg-card p-4 text-left shadow-border"> <div className="flex items-baseline justify-between gap-3"> <h3 className="text-13 font-medium text-foreground"> Call recording </h3> <span className="text-xs text-muted-foreground"> Northwind Freight, Thursday, Sep 24 </span> </div> <div className="flex items-center gap-2 sm:gap-3"> <IconAction label={playing ? "Pause recording" : "Play recording"} variant="secondary" size="icon" className="shrink-0 rounded-full" onClick={() => { if (progress >= 1) setProgress(0); setPlaying((p) => !p); }} > {playing ? ( <PauseIcon aria-hidden="true" className="fill-current" /> ) : ( <PlayIcon aria-hidden="true" className="translate-x-px fill-current" /> )} </IconAction> <div className="hidden items-center sm:flex"> <IconAction label="Back 10 seconds" onClick={() => seekBy(-10)} > <RotateCcwIcon aria-hidden="true" /> </IconAction> <IconAction label="Forward 10 seconds" onClick={() => seekBy(10)} > <RotateCwIcon aria-hidden="true" /> </IconAction> </div> <span className="w-10 shrink-0 text-right text-xs text-foreground tabular-nums"> {formatClock(progress * duration)} </span> <StaticWaveform peaks={peaks} progress={progress} onSeek={setProgress} duration={duration} height={36} label="Recording position" className="min-w-0 flex-1" /> <span className="w-10 shrink-0 text-xs text-muted-foreground tabular-nums"> {formatClock(duration)} </span> <DropdownMenu> <DropdownMenuTrigger render={ <Button type="button" variant="ghost" size="sm" aria-label={`Playback speed, ${rate}x`} className="w-12 shrink-0 tabular-nums" /> } > {rate}x </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-32"> <DropdownMenuRadioGroup value={String(rate)} onValueChange={(value) => setRate(Number(value))} > {[0.75, 1, 1.25, 1.5, 2].map((option) => ( <DropdownMenuRadioItem key={option} value={String(option)} className="tabular-nums" > {option}x </DropdownMenuRadioItem> ))} </DropdownMenuRadioGroup> </DropdownMenuContent> </DropdownMenu> <span className="hidden sm:inline-flex"> <IconAction label="Download recording" onClick={() => toast.add({ title: "Recording download started", description: "cv_48213.wav, dual channel", }) } > <DownloadIcon aria-hidden="true" /> </IconAction> </span> </div> </div> );}Usage#
Waveform draws audio in two ways. Waveform is a row of live level bars for a call in progress: the minimized call pill, the contact center call bar, a supervisor's monitor. StaticWaveform draws a recording from precomputed peaks and, given onSeek, becomes a slider you drag, click or step with the keyboard. Both are ink, never indigo. The common mistake is letting the live bars stand in for the call's status: they show that sound is happening, while a status label says whether the call is live, on hold or muted.
When to use
- Beside a live call's status and clock, to show that someone is speaking: On call 3:42 and the bars.
- Inside compact call chrome where the orb doesn't fit: the minimized call pill, a queue row being monitored.
- For a call recording or voicemail with a play button, the position, the duration and a speed menu.
- Read-only (no
onSeek) for a recording preview in a list, where the row opens the full player. - With
simulateVoiceLevelto drive demos and previews that have no audio.
When not to use
- For the centerpiece of a live call with the agent's state. Use Voice orb
- For latency or any other value over time. Use Latency timeline
- For a trend in a table cell or a stat. Use Mini chart
- For a plain progress bar of a known task. Use Progress
- To pick a voice and play a sample of it. Use Voice picker
The Ink Fill Rule
The Label-Beside-Color Rule
The Tabular Figures Rule
Anatomy#
- Live bars.
Waveform: round-ended bars, 2px wide with 2px gaps by default, tallest in the middle. Each bar scales vertically with the smoothed level. - Played peaks.
StaticWaveformpeaks before the playhead, in Ink 80. - Unplayed peaks. Peaks after the playhead in Ink 20. Every peak is at least 8% of the height, so silence still shows.
- Hover line. A 1px Ink 50 line under the pointer on a seekable waveform, showing where a click will land. Not shown on touch.
- Focus ring. A 3px Focus Indigo ring at 40% on keyboard focus, with 6px corners.
Examples#
Live tones
Ink for the agent or the person you are listening to, muted for the other side, current to inherit the parent's color. Primary exists but breaks the Ink Fill Rule.
import { Waveform } from "@oration/canon/components/voice/waveform";import { cn } from "@oration/canon/lib/utils";export function Tones() { const rows = [ { tone: "ink" as const, label: "Nora is speaking" }, { tone: "muted" as const, label: "Halcyon is speaking" }, { tone: "current" as const, label: "Inherits text color" }, { tone: "primary" as const, label: "Primary (avoid)" }, ]; return ( <div className="flex flex-col gap-3"> {rows.map((row) => ( <div key={row.tone} className="flex items-center gap-4"> <Waveform tone={row.tone} className={cn( row.tone === "current" && "text-destructive", )} /> <span className="text-13 text-muted-foreground"> {row.label} </span> </div> ))} </div> );}Bars and height
Set bars, height, barWidth and gap to fit the chrome. These are the sizes the product uses.
import { Waveform } from "@oration/canon/components/voice/waveform";export function Density() { const presets = [ { bars: 10, height: 16, where: "Call pill" }, { bars: 18, height: 18, where: "Call bar" }, { bars: 24, height: 24, where: "Default" }, { bars: 36, height: 32, barWidth: 3, gap: 3, where: "Monitor panel" }, ]; return ( <div className="flex flex-wrap items-end justify-center gap-8"> {presets.map((preset) => ( <div key={preset.where} className="flex flex-col items-center gap-2" > <Waveform bars={preset.bars} height={preset.height} barWidth={preset.barWidth} gap={preset.gap} /> <span className="flex flex-col items-center text-xs"> <span className="font-medium text-foreground"> {preset.where} </span> <span className="text-muted-foreground tabular-nums"> {preset.bars} bars, {preset.height}px </span> </span> </div> ))} </div> );}Driving the level
Pass level from your audio source and active for whose turn it is. Here simulateVoiceLevel stands in for two speakers, seeded differently so they don't match.
import { simulateVoiceLevel, Waveform } from "@oration/canon/components/voice/waveform";import * as React from "react";export function OwnLevel() { const [t, setT] = React.useState(0); React.useEffect(() => { const started = performance.now(); const id = window.setInterval( () => setT((performance.now() - started) / 1000), 60, ); return () => window.clearInterval(id); }, []); const agentTurn = Math.floor(t / 3) % 2 === 0; const agent = agentTurn ? simulateVoiceLevel(t, 1) : 0; const caller = agentTurn ? 0 : simulateVoiceLevel(t, 4); return ( <div className="flex w-full max-w-sm flex-col gap-3"> <div className="flex items-center gap-3"> <span className="w-20 text-13 text-foreground">Nora</span> <Waveform level={agent} active={agentTurn} bars={28} /> <span className="ml-auto text-xs text-muted-foreground tabular-nums"> {agent.toFixed(2)} </span> </div> <div className="flex items-center gap-3"> <span className="w-20 text-13 text-foreground">Caller</span> <Waveform level={caller} active={!agentTurn} bars={28} tone="muted" /> <span className="ml-auto text-xs text-muted-foreground tabular-nums"> {caller.toFixed(2)} </span> </div> </div> );}In the call bar
The contact center strip: status label, clock, bars, then the controls. Mute or hold and the bars fall flat while the label says why.
import { Button } from "@oration/canon/components/button";import { IconAction } from "@oration/canon/components/icon-action";import { StatusLabel } from "@oration/canon/components/status-dot";import { toast } from "@oration/canon/components/toast";import { formatClock, Waveform } from "@oration/canon/components/voice/waveform";import { MicIcon, MicOffIcon, PhoneOffIcon } from "lucide-react";import * as React from "react";export function CallBar() { const [muted, setMuted] = React.useState(false); const [held, setHeld] = React.useState(false); const [seconds, setSeconds] = React.useState(222); React.useEffect(() => { const id = window.setInterval(() => setSeconds((s) => s + 1), 1000); return () => window.clearInterval(id); }, []); return ( <div className="flex w-full max-w-2xl flex-wrap items-center gap-x-3 gap-y-2 rounded-xl bg-background px-3 py-2 shadow-border"> <StatusLabel tone={held ? "warning" : "primary"} pulse={!held}> {held ? "On hold" : muted ? "On call, muted" : "On call"} </StatusLabel> <span className="text-13 text-foreground tabular-nums"> {formatClock(seconds)} </span> <Waveform active={!muted && !held} bars={18} height={18} /> <span className="truncate text-13 text-muted-foreground"> Jordan Lee with Orchard Street </span> <div className="ml-auto flex items-center gap-1"> <IconAction label={muted ? "Unmute" : "Mute"} active={muted} onClick={() => setMuted((m) => !m)} > {muted ? ( <MicOffIcon aria-hidden="true" /> ) : ( <MicIcon aria-hidden="true" /> )} </IconAction> <Button type="button" variant="ghost" size="sm" onClick={() => setHeld((h) => !h)} > {held ? "Resume call" : "Hold"} </Button> <Button type="button" variant="destructive" size="sm" onClick={() => toast.add({ title: "Call ended", description: `Orchard Street, ${formatClock(seconds)}. Wrap-up is open.`, }) } > <PhoneOffIcon data-icon="inline-start" aria-hidden="true" /> End </Button> </div> </div> );}In the minimized call pill
Ten bars at 16px. Ink while the agent speaks, muted while it listens to you, flat when you are muted.
import { AgentAvatar } from "@oration/canon/components/agent-avatar";import { IconAction } from "@oration/canon/components/icon-action";import { toast } from "@oration/canon/components/toast";import { formatClock, Waveform } from "@oration/canon/components/voice/waveform";import { MicIcon, MicOffIcon, PhoneOffIcon } from "lucide-react";import * as React from "react";export function CallPill() { const [speaking, setSpeaking] = React.useState(true); const [muted, setMuted] = React.useState(false); const [seconds, setSeconds] = React.useState(84); React.useEffect(() => { const id = window.setInterval(() => { setSeconds((s) => s + 1); }, 1000); const turn = window.setInterval(() => setSpeaking((s) => !s), 2800); return () => { window.clearInterval(id); window.clearInterval(turn); }; }, []); const hearing = !speaking && !muted; return ( <div className="flex h-12 items-center gap-1 rounded-full bg-popover p-1.5 shadow-popover"> <button type="button" onClick={() => toast.add({ title: "Call restored", description: "Test call with Nora.", }) } aria-label="Restore call with Nora" className="flex h-9 min-w-0 items-center gap-2 rounded-full pr-2.5 pl-0.5 outline-none transition-colors duration-150 hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50" > <AgentAvatar agent={{ id: "ag_payment_status", name: "Payments desk agent", }} size={28} state="live" /> <span className="text-13 font-medium text-foreground"> Nora </span> <span className="text-xs text-muted-foreground tabular-nums"> {formatClock(seconds)} </span> <Waveform bars={10} height={16} active={speaking || hearing} tone={speaking ? "ink" : "muted"} /> </button> <IconAction label={muted ? "Unmute" : "Mute"} active={muted} onClick={() => setMuted((m) => !m)} className="rounded-full" > {muted ? ( <MicOffIcon aria-hidden="true" /> ) : ( <MicIcon aria-hidden="true" /> )} </IconAction> <IconAction label="End call" onClick={() => toast.add({ title: "Call ended" })} className="rounded-full text-destructive hover:text-destructive" > <PhoneOffIcon aria-hidden="true" /> </IconAction> </div> );}Read-only recordings
Without onSeek the waveform is an image. A played voicemail is drawn at full progress. The row's play button opens the player.
- Halcyon accounts payable9:12 AM, 0:48
- Northwind FreightYesterday, 1:11
import { IconAction } from "@oration/canon/components/icon-action";import { toast } from "@oration/canon/components/toast";import { formatClock, StaticWaveform } from "@oration/canon/components/voice/waveform";import { PlayIcon } from "lucide-react";export function ReadOnly() { const voicemails = [ { id: "vm1", from: "Halcyon accounts payable", at: "9:12 AM", length: 48, heard: false, }, { id: "vm2", from: "Northwind Freight", at: "Yesterday", length: 71, heard: true, }, ]; return ( <ul className="flex w-full max-w-md flex-col divide-y divide-border rounded-xl bg-card shadow-border"> {voicemails.map((vm, index) => ( <li key={vm.id} className="flex items-center gap-3 px-3 py-2.5"> <IconAction label={`Play voicemail from ${vm.from}`} variant="outline" onClick={() => toast.add({ title: "Playing voicemail", description: vm.from, }) } > <PlayIcon aria-hidden="true" className="translate-x-px fill-current" /> </IconAction> <div className="flex min-w-0 flex-1 flex-col gap-1"> <div className="flex items-baseline justify-between gap-2"> <span className="truncate text-13 font-medium text-foreground"> {vm.from} </span> <span className="shrink-0 text-xs text-muted-foreground tabular-nums"> {vm.at}, {formatClock(vm.length)} </span> </div> <StaticWaveform peaks={Array.from( { length: 64 }, (_, i) => Math.round( (0.2 + 0.75 * Math.abs( Math.sin( (i + index * 9) * 1.3, ) * Math.cos(i * 0.27), )) * 100, ) / 100, )} progress={vm.heard ? 1 : 0} height={20} label={`Voicemail from ${vm.from}, ${vm.heard ? "played" : "not played"}`} /> </div> </li> ))} </ul> );}States#
import { StaticWaveform, Waveform } from "@oration/canon/components/voice/waveform";import { cn } from "@oration/canon/lib/utils";export function StatesMatrix() { const peaks = Array.from( { length: 40 }, (_, i) => Math.round( (0.25 + 0.7 * Math.abs(Math.sin(i * 1.7) * Math.cos(i * 0.31))) * 100, ) / 100, ); const cells = [ { label: "Not started", progress: 0, seek: true, className: "" }, { label: "Part played", progress: 0.42, seek: true, className: "" }, { label: "Focus visible", progress: 0.42, seek: true, className: "ring-3 ring-ring/40", }, { label: "Read-only", progress: 0.42, seek: false, className: "" }, ]; return ( <div className="flex w-full flex-col gap-6"> <div className="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2"> {cells.map((cell) => ( <div key={cell.label} className="flex flex-col gap-2"> <span className="text-xs text-muted-foreground"> {cell.label} </span> <StaticWaveform peaks={peaks} progress={cell.progress} onSeek={cell.seek ? () => {} : undefined} duration={180} label={`${cell.label} example`} className={cn( "pointer-events-none", cell.className, )} /> </div> ))} </div> <div className="grid grid-cols-2 gap-6"> <div className="flex flex-col gap-2"> <span className="text-xs text-muted-foreground"> Live, active </span> <Waveform /> </div> <div className="flex flex-col gap-2"> <span className="text-xs text-muted-foreground"> Live, inactive </span> <Waveform active={false} /> </div> </div> </div> );}| State | Treatment |
|---|---|
| Live, active | Bars follow level, or a simulated voice when level is left out. Peaks land fast and fall slowly. |
| Live, inactive | active={false} lets the bars fall to 12% height, then stops the animation loop. Use it for muted, on hold and silence. |
| Live, reduced motion | No loop runs. The bars hold their envelope shape at a height that steps with level in quarters, or flat when inactive. |
| Recording, not started | Every peak in Ink 20. |
| Recording, playing | Peaks before progress turn Ink 80. The caller advances progress; the waveform doesn't play audio. |
| Recording, hover | The hover line follows the pointer on seekable waveforms. |
| Recording, dragging | The waveform captures the pointer, so a drag keeps seeking even when it leaves the bars. |
| Recording, focus visible | The 3px ring. Arrow keys step 5 seconds from here. |
| Read-only | Without onSeek it is role="img", not focusable, with no hover line. |
Behavior#
Waveformruns onerequestAnimationFrameloop that writesscaleYto each bar directly. It stops once the bars settle afteractiveturns false and restarts when it turns true again.- Smoothing is frame-rate independent: 40% of the way to a louder target per 60fps frame, 10% toward a quieter one.
- Each bar gets its own flutter on top of a centered envelope, so the row reads as speech rather than an equalizer.
StaticWaveformis controlled: passprogress(0 to 1) and update it inonSeek. It never changes position on its own.- Pointer down seeks at once and captures the pointer; moving while held keeps seeking. Hover (not touch) shows the line.
- Keyboard steps are 5 seconds when
durationis given, otherwise 5%. Page Up and Page Down step twice as far. simulateVoiceLevel(t, seed)returns a speech-like envelope for a time in seconds; different seeds decorrelate two speakers.formatClock(seconds)gives m:ss.
Do and don't#
Content#
- Name the slider for what it scrubs: Recording position, Voicemail position. The default is Playback position.
- Give a live waveform a
labelonly when it stands alone, such as Call audio in a supervisor's monitor. Beside a status label it stays decorative. - Write positions and durations as m:ss in tabular figures: 2:14 of 7:05.
- Label the play button with its object: Play recording, Pause recording.
Accessibility#
- A seekable
StaticWaveformisrole="slider"witharia-valuenowin percent andaria-valuetextin time, such as 2:14 of 7:05, whendurationis given. - Without
onSeekit isrole="img"named bylabel. Waveformisaria-hiddenunless you passlabel, which makes itrole="img". Beside a visible status it should stay hidden.- Live bars don't animate under reduced motion; they hold a stepped shape. The status label carries the state either way.
- Hit area: the waveform is full width and at least 32px tall by default; keep it at 32px or more on touch.
- Play and pause are separate buttons with names. The waveform never starts playback itself.
| Keys | Action |
|---|---|
| Tab | Focuses a seekable waveform. |
| → | Forward 5 seconds, or 5% without a duration. ↑ does the same. |
| ← | Back 5 seconds, or 5%. ↓ does the same. |
| Page Up | Forward 10 seconds, or 10%. |
| Page Down | Back 10 seconds, or 10%. |
| Home | Jumps to the start. |
| End | Jumps to the end. |
Design tokens#
| Token | Used for |
|---|---|
--foreground | Live bars at 65% (ink); peaks at 80% played and 20% unplayed; hover line at 50% |
--muted-foreground | Live bars at tone="muted", for the other speaker |
--primary | Live bars at tone="primary" (avoid) |
currentColor | Live bars at tone="current", inheriting the parent's text color |
--ring | Focus ring at 40% on a seekable waveform |
--radius-md | 6px corners of the focus ring (rounded-md) |
API reference#
Waveform
Live level bars.
Other props spread onto Nothing. Only the props below are read..
| Prop | Type | Default | Description |
|---|---|---|---|
level | number | No default | 0 to 1. Leave it out while active for a simulated voice. |
bars | number | 24 | Number of bars. |
active | boolean | true | False lets the bars fall flat and stops the loop. |
tone | "ink" | "muted" | "primary" | "current" | "ink" | Bar color. muted for the other speaker; current inherits. |
height | number | 24 | Height in px. |
barWidth | number | 2 | Bar width in px. |
gap | number | 2 | Gap between bars in px. |
label | string | No default | Makes it role="img" with this name. Otherwise decorative. |
className | string | No default | Merged onto the row. |
StaticWaveform
A recording's peaks, and a seek slider when onSeek is given.
Other props spread onto Nothing.
| Prop | Type | Default | Description |
|---|---|---|---|
peaksRequired | number[] | No default | Precomputed peaks, 0 to 1. One bar each, sharing the width. |
progress | number | 0 | Playhead from 0 to 1. Peaks before it are drawn darker. |
onSeek | (progress: number) => void | No default | Makes it a slider. Called with 0 to 1 on click, drag and keys. |
duration | number | No default | Seconds. Sets the spoken position and the 5-second key step. |
height | number | 32 | Height in px. |
label | string | "Playback position" | Accessible name of the slider or image. |
className | string | No default | Merged onto the root. |
simulateVoiceLevel
A speech-like level for demos: syllable flutter, phrase swells and pauses.
| Prop | Type | Default | Description |
|---|---|---|---|
tRequired | number | No default | Time in seconds. |
seed | number | 0 | Decorrelates two speakers. |
returns | number | No default | Level from 0 to 1. |
smoothLevel
Frame-rate independent attack and release toward a target level, as the bars use it.
| Prop | Type | Default | Description |
|---|---|---|---|
currentRequired | number | No default | The current level. |
targetRequired | number | No default | The level to move toward. |
dtRequired | number | No default | Seconds since the last frame. |
returns | number | No default | The next level. |
formatClock
Formats seconds as m:ss.
| Prop | Type | Default | Description |
|---|---|---|---|
secondsRequired | number | No default | Seconds, rounded. |
returns | string | No default | For example 7:05. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
No product surface passes a real level: every live waveform runs on the simulated voice, and simulateVoiceLevel isn't imported anywhere in the app.
formatClock takes seconds and has no hour unit (an hour reads 60:00). The history recording player and the contact center each use their own clock formatter, one taking milliseconds, one handling hours.
tone="primary" paints the bars indigo. Nothing uses it, and it breaks the Ink Fill Rule.
StaticWaveform has no disabled or processing state. The history player hand-rolls a Well Gray box for The recording is still processing.
smoothLevel is exported but missing from the registry's export list.