Voice picker
Search, filter and preview voices, with accent and gender tags.
Voice
How Nora sounds on calls. Press play to hear a line in each voice.
6 voices
import { SettingsGroup, SettingsSection } from "@oration/canon/components/settings-section";import { type Voice, VoicePicker } from "@oration/canon/components/voice-picker";import * as React from "react";export function Hero() { const voices: Voice[] = [ { id: "nora", name: "Nora", gender: "female", accent: "American", age: "30s", description: "Warm and unhurried. Sounds like someone who has the answer in front of them.", bestFor: ["Payment status", "Inbound support"], provider: "ElevenLabs", previewText: "Thanks for calling Cedarline supplier support. I can check a payment for you right now.", }, { id: "callum", name: "Callum", gender: "male", accent: "American", age: "30s", description: "Clear and precise with even pacing. Reads long reference numbers well.", bestFor: ["Remittance details", "Reading numbers"], provider: "ElevenLabs", previewText: "The remittance for invoice 48213 went out by ACH on September 24 for 12,480 dollars.", }, { id: "imani", name: "Imani", gender: "female", accent: "American", age: "20s", description: "Bright and encouraging without sounding scripted.", bestFor: ["Vendor onboarding", "Web calls"], provider: "ElevenLabs", }, { id: "rafael", name: "Rafael", gender: "male", accent: "Mexican Spanish", age: "40s", description: "Bilingual and reassuring. Switches between English and Spanish naturally.", bestFor: ["Spanish line", "Language mirroring"], provider: "ElevenLabs", previewText: "Gracias por llamar a Cedarline. ¿Me puede dar el número de factura, por favor?", }, { id: "declan", name: "Declan", gender: "male", accent: "Irish", age: "40s", description: "Easygoing and patient. Good for long reconciliation calls.", bestFor: ["Statement reconciliation"], provider: "ElevenLabs", }, { id: "grace", name: "Grace", gender: "female", accent: "British", age: "50s", description: "Calm and formal. Suits escalations and overdue balances.", bestFor: ["Escalations", "Collections"], provider: "ElevenLabs", }, ]; const [value, setValue] = React.useState("nora"); return ( <SettingsSection title="Voice" description="How Nora sounds on calls. Press play to hear a line in each voice." className="w-full max-w-2xl py-0" > <SettingsGroup className="p-4"> <VoicePicker voices={voices} value={value} onChange={setValue} /> </SettingsGroup> </SettingsSection> );}Usage#
Voice picker is how people choose what an agent sounds like: a searchable list of voices with gender and accent filters, a short preview on every row and use-case tags. In Oration it sits in an agent's voice settings and in onboarding. The mistake is choosing from names alone; each row should say how the voice sounds and which calls it suits, and people should hear it say a real line before they pick.
When to use
- To choose an agent's text-to-speech voice from the selected provider's catalog.
- When people need to hear a voice before choosing, saying a line the agent would actually say.
- When the catalog is long enough to search and filter, roughly more than five voices.
- In onboarding, to give the first agent a voice.
When not to use
- For choosing the speech provider and model the voices come from. Use Provider model picker
- For a short list of plain options with no previews. Use Radio group
- For two to four rich options that aren't voices. Use Choice card
- For showing an agent speaking during a live call. Use Voice orb
- For playing back a recorded call. Use Waveform
Hear it before you pick it
previewText to a line from the agent's real calls, with the numbers and names it must read well: The remittance for invoice 48213 went out by ACH on September 24.The Label-Beside-Color Rule
aria-checked together. Playing swaps the icon to Stop, shows the level meter and announces Playing.The Ink Fill Rule
Anatomy#
- Search. An input group with a search icon and Search by name, accent or use.
- Filter chips. 28px pressed toggles for gender, then accent. A group only appears when it has two or more values.
- Count. 12px tabular text: 6 voices, or 2 of 6 voices while filtering.
- Voice row. 8px padding and
rounded-lg. The selected row gets a 6% indigo tint and a 22% indigo inset edge. - Preview button. A 32px ghost icon button: Play, or a filled Stop while playing.
- Name. 14px at weight 500.
- Meta. Gender, accent and age in 12px muted text.
- Playing meter. Five 2px ink bars beside the meta while the preview plays.
- Provider. 12px muted text at the end of the name row.
- Description. 13px muted text: how the voice sounds and where it fits.
- Best-for tags. 20px gray tags for the calls it suits.
Examples#
Spoken previews
With speakPreview, the browser reads each voice's previewText aloud where speech synthesis is available. Write a line with the numbers and names the agent must say well.
2 voices
import { type Voice, VoicePicker } from "@oration/canon/components/voice-picker";import * as React from "react";export function SpokenPreview() { const voices: Voice[] = [ { id: "callum", name: "Callum", gender: "male", accent: "American", age: "30s", description: "Clear and precise with even pacing. Reads long reference numbers well.", bestFor: ["Remittance details"], previewText: "The remittance for invoice 48213 went out by ACH on September 24 for 12,480 dollars.", }, { id: "nora", name: "Nora", gender: "female", accent: "American", age: "30s", description: "Warm and unhurried.", bestFor: ["Payment status"], previewText: "Invoice INV-20931 from Northwind Freight is scheduled for Friday, October 2.", }, ]; const [value, setValue] = React.useState("callum"); return ( <VoicePicker voices={voices} value={value} onChange={setValue} speakPreview className="w-full max-w-xl" /> );}Filters only when they help
A filter group appears only when it has two or more values. Every voice here is American, so only the gender chips show.
3 voices
import { type Voice, VoicePicker } from "@oration/canon/components/voice-picker";import * as React from "react";export function FewVoices() { const voices: Voice[] = [ { id: "nora", name: "Nora", gender: "female", accent: "American", description: "Warm and unhurried.", bestFor: ["Payment status"], }, { id: "callum", name: "Callum", gender: "male", accent: "American", description: "Clear and precise with even pacing.", bestFor: ["Remittance details"], }, { id: "imani", name: "Imani", gender: "female", accent: "American", description: "Bright and encouraging without sounding scripted.", bestFor: ["Vendor onboarding"], }, ]; const [value, setValue] = React.useState("imani"); return ( <VoicePicker voices={voices} value={value} onChange={setValue} className="w-full max-w-xl" /> );}In onboarding
A short catalog inside a step, renamed with label and followed by the step's one filled button.
Choose a voice for your supplier line
You can change it later in the agent's voice settings.
3 voices
import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import { type Voice, VoicePicker } from "@oration/canon/components/voice-picker";import * as React from "react";export function Onboarding() { const voices: Voice[] = [ { id: "nora", name: "Nora", gender: "female", accent: "American", age: "30s", description: "Warm and unhurried. Sounds like someone who has the answer in front of them.", bestFor: ["Payment status"], }, { id: "callum", name: "Callum", gender: "male", accent: "American", age: "30s", description: "Clear and precise. Reads long reference numbers well.", bestFor: ["Remittance details"], }, { id: "grace", name: "Grace", gender: "female", accent: "British", age: "50s", description: "Calm and formal. Suits escalations and overdue balances.", bestFor: ["Escalations"], }, ]; const [value, setValue] = React.useState("nora"); const chosen = voices.find((voice) => voice.id === value); return ( <div className="flex w-full max-w-xl flex-col overflow-hidden rounded-xl bg-card shadow-border"> <div className="flex flex-col gap-1 px-5 pt-5 pb-3"> <p className="text-base font-medium text-foreground"> Choose a voice for your supplier line </p> <p className="text-13 text-muted-foreground"> You can change it later in the agent's voice settings. </p> </div> <div className="px-5 pb-4"> <VoicePicker voices={voices} value={value} onChange={setValue} label="Agent voice" /> </div> <div className="flex items-center justify-end gap-2 border-t border-border bg-muted/50 px-5 py-3"> <Button type="button" variant="ghost" onClick={() => toast.add({ title: "Back to naming your agent" }) } > Back </Button> <Button type="button" onClick={() => toast.add({ type: "success", title: `${chosen?.name ?? "Voice"} it is`, description: "Next, connect the number suppliers call.", }) } > Continue </Button> </div> </div> );}Disabled
The voice radios are inert; previews still play. Say who can change the voice.
2 voices
Only admins can change Nora's voice. Ask Maya Okafor for access.
import { type Voice, VoicePicker } from "@oration/canon/components/voice-picker";import * as React from "react";export function Disabled() { const voices: Voice[] = [ { id: "nora", name: "Nora", gender: "female", accent: "American", age: "30s", description: "Warm and unhurried.", bestFor: ["Payment status"], provider: "ElevenLabs", }, { id: "callum", name: "Callum", gender: "male", accent: "American", age: "30s", description: "Clear and precise with even pacing.", bestFor: ["Remittance details"], provider: "ElevenLabs", }, ]; const [value, setValue] = React.useState("nora"); return ( <div className="flex w-full max-w-xl flex-col gap-2"> <VoicePicker voices={voices} value={value} onChange={setValue} disabled /> <p className="text-13 text-muted-foreground"> Only admins can change Nora's voice. Ask Maya Okafor for access. </p> </div> );}States#
| State | Treatment |
|---|---|
| Rest | Rows on the surface with no fill. |
| Hover | A shared highlight follows the pointer down the list. |
| Selected | 6% indigo tint, 22% indigo inset edge and aria-checked. |
| Focus visible | A 3px Focus Indigo ring at 40% on the row's text, or on the preview button. |
| Playing | The button shows a filled square in ink and is named Stop, the meter animates beside the meta and Playing is announced. |
| Filter chip pressed | The same indigo tint and edge as a selected row. At rest chips are Well Gray at 70%; they scale to 0.96 while pressed. |
| Filtered | The count reads 2 of 6 voices. |
| No matches | A well with No voices match "Scottish". (or these filters) and Clear filters. |
| Disabled | Voice rows at 50% and inert; the list carries aria-disabled. |
Behavior#
- It is controlled:
valueis the selected voice id andonChangegets the new id. - Search matches name, accent, gender, age, description, provider and best-for tags, ignoring case.
- Chips within a group widen the match (American or British); groups narrow it (female and British). Clear filters resets search and both groups.
- One preview plays at a time. Playing another stops the first, and pressing the playing one stops it. Unmounting stops playback.
- Without
speakPreview, a preview is simulated for three seconds. With it, and where the browser supports speech synthesis, the browser speakspreviewText, or Hi, I'm Nora. How can I help you today?, and stops when it finishes. - Filtering never changes the selection. If the selected voice is hidden, the first visible row takes the tab stop.
- The list is a roving radio group: arrow keys move and select, wrapping at the ends, and Home and End jump. Clicking a row's padding selects it too.
Do and don't#
1 voice
1 voice
1 voice
1 voice
Content#
- Voice names are first names: Nora, Callum, Rafael.
- Descriptions are how it sounds, then where it fits: Clear and precise with even pacing. Reads long reference numbers well.
- Best-for tags are call types: Payment status, Remittance details, Spanish line.
- Accents are written as people say them: American, British, Mexican Spanish. Ages are decades: 30s.
- Preview lines come from real calls and include an amount, a date or an invoice number.
Accessibility#
- The list is a
role="radiogroup"named bylabel(default Voice). Rows are radios; their name is the voice name and the rest of the row. - Search is named Search voices and controls the list. The count is a polite live region, so filtering is announced.
- Filter chips are toggle buttons with
aria-pressed, grouped as Gender and Accent. - Preview buttons are named Play Nora preview and Stop Nora preview. The meter is
aria-hidden; a visually hidden Playing carries the state. - Best-for tags are introduced with a visually hidden Best for:.
- Only the selected row's preview button is in the tab order, so previewing another voice by keyboard means arrowing to it, which selects it.
| Keys | Action |
|---|---|
| Tab | Moves through search, filter chips, then the selected row's preview button and radio. |
| ↑↓ | Move to and select the previous or next voice. Left and Right do the same. |
| Home | Selects the first visible voice. End selects the last. |
| Space | Toggles a focused chip, or plays and stops a focused preview. Enter does the same. |
Design tokens#
| Token | Used for |
|---|---|
--primary | Selected row and pressed chip: 6% tint and 22% inset edge |
--muted | Filter chips at 70%, solid on hover; the no-matches well |
--foreground | Playing meter bars at 65% |
--tag-gray | Best-for tags |
--muted-foreground | Meta, provider, description, count, idle preview icon |
--ring | Focus ring at 40% |
API reference#
VoicePicker
Search, filters and a radio list of voices with previews.
Other props spread onto Doesn't spread props; renders <div data-slot="voice-picker">.
| Prop | Type | Default | Description |
|---|---|---|---|
voicesRequired | Voice[] | No default | The catalog, in display order. |
valueRequired | string | No default | Id of the selected voice. |
onChangeRequired | (id: string) => void | No default | Called with the newly selected id. |
speakPreview | boolean | false | Speak previews with the browser's speech synthesis instead of simulating them. |
label | string | "Voice" | Accessible name of the list. |
disabled | boolean | No default | Disables the voice radios. |
className | string | No default | Merged onto the root. |
Voice
One voice.
| Prop | Type | Default | Description |
|---|---|---|---|
idRequired | string | No default | Stable id, used as the value. |
nameRequired | string | No default | The voice's name. |
descriptionRequired | string | No default | How it sounds and where it fits. |
gender | string | No default | Filterable. Compared lowercased and shown capitalized. |
accent | string | No default | Filterable, shown as written. |
age | string | No default | A decade, like 30s. |
bestFor | string[] | No default | Call types it suits, shown as tags. |
provider | string | No default | Shown at the end of the name row. |
previewText | string | No default | What the preview says when spoken. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Meta is joined with middle dots (Female · American · 30s), which Canon's writing rules ban.
disabled only disables the voice radios. Search, filter chips and previews keep working.
Previews share the roving tab stop with the radios, so only the selected row's preview is reachable by Tab.
The level meter's comment says it holds still under reduced motion, but it animates regardless; it has no reduced-motion check of its own.
Filter chips pass text-13 next to a text color through the cn package, which drops the size, and they are a local copy rather than the Filter chip component.
The app passes the provider id as provider, so rows in agent settings read elevenlabs rather than ElevenLabs.
Simulated previews make no sound; only speakPreview produces audio, and nothing plays the provider's real sample.