Voice picker
Choose a voice after hearing it. Each voice has its own preview button, which becomes stop with a progress ring while the sample plays.
On this page
Pick a voice and hear a sample first. The play button becomes stop with a progress ring.
Installation
Usage
import { VoicePicker } from "@/components/ui/chatcn/voice-picker"
<VoicePicker
voices={[
{ id: "marlo", name: "Marlo", description: "Warm and measured", previewUrl: "/voices/marlo.mp3" },
{ id: "cove", name: "Cove", description: "Low and calm", tags: ["New"], previewUrl: "/voices/cove.mp3" },
]}
value={voice}
onValueChange={setVoice}
/>With previewUrl the picker plays samples itself. For text-to-speech previews generated on demand, control playback instead: pass onPreview and onStopPreview, and report previewing, previewState and previewProgress back.
<VoicePicker
voices={voices}
value={voice}
onValueChange={setVoice}
previewing={sample.voiceId}
previewState={sample.loading ? "loading" : "playing"}
previewProgress={sample.currentTime / sample.duration}
previewError={sample.error ? { id: sample.voiceId } : undefined}
onPreview={(id) => sample.play(id, "Here is how I sound reading your build logs.")}
onStopPreview={sample.stop}
/>Examples
Popover
The compact form for a voice-mode header or a composer. Arrow keys move the choice without closing; a click or Enter picks and closes, and closing stops any sample. A sample that fails says so in the row, in place of the description.
The compact trigger for a voice-mode header, with a sample that fails to load.
States
| State | Shows |
|---|---|
loading | A spinner and Loading voices |
| No voices | An empty state; set emptyMessage to say where voices come from |
| Disabled voice | Dimmed, skipped by the arrow keys, with a lock and disabledReason |
| Sample loading | The preview button shows the spinner |
| Sample playing | Stop, with a hairline ring that fills with progress |
previewError | A red alert icon and a fix in place of the description |
Props
| Prop | Type | Notes |
|---|---|---|
voices | VoiceOption[] | id, name, description?, tags?, previewUrl?, disabled?, disabledReason? |
value, defaultValue, onValueChange | string | Controlled or uncontrolled |
variant | "inline" | "popover" | Default inline |
previewing | string | Voice whose sample is loading or playing (controlled preview) |
previewState | "loading" | "playing" | Default playing |
previewProgress | number | 0 to 1, drives the ring |
onPreview, onStopPreview | (id) => void, () => void | Control playback yourself |
previewError | { id, message? } | A failed sample. The message should say how to fix it |
loading, disabled | boolean | |
label | string | Accessible name of the radio group. Default Voice |
emptyMessage | ReactNode | Shown with no voices |
open, defaultOpen, onOpenChange | boolean | Popover state |
side, align | Popover placement |
Keyboard
| Key | Action |
|---|---|
Tab | The chosen voice, then each preview button |
| Arrow keys | Move to the next or previous voice and choose it, skipping disabled ones |
Space, Enter | Choose the focused voice. In the popover, Enter also closes it |
Enter, Space on a preview | Play or stop the sample |
Esc | Close the popover and stop the sample |
Accessibility
- The list is a
radiogroupwith roving focus: one Tab stop, arrows move and choose. - The preview button sits beside each radio, never inside it, and is named Preview Marlo or Stop preview of Marlo.
- Descriptions, disabled reasons and preview errors are linked to their radio with
aria-describedby. - The chosen voice is marked by a filled radio and a filled row, not color alone. Touch targets grow to 44 px on coarse pointers.
Motion
- Play, spinner and stop crossfade in one footprint with a 150 ms blur swap.
- The progress ring advances with a 200 ms linear transition between updates; under reduced motion it steps.
- The radio dot fades and scales in over 150 ms. The popover opens from its trigger in 180 ms, instantly when opened with the keyboard.