Skip to content
VoiceVoice pickerMenu

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.

Pick a voice and hear a sample first. The play button becomes stop with a progress ring.

Installation

$ pnpm dlx shadcn@latest add https://chatcn.dev/r/voice-picker.json

Usage

tsx
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.

tsx
<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

StateShows
loadingA spinner and Loading voices
No voicesAn empty state; set emptyMessage to say where voices come from
Disabled voiceDimmed, skipped by the arrow keys, with a lock and disabledReason
Sample loadingThe preview button shows the spinner
Sample playingStop, with a hairline ring that fills with progress
previewErrorA red alert icon and a fix in place of the description

Props

PropTypeNotes
voicesVoiceOption[]id, name, description?, tags?, previewUrl?, disabled?, disabledReason?
value, defaultValue, onValueChangestringControlled or uncontrolled
variant"inline" | "popover"Default inline
previewingstringVoice whose sample is loading or playing (controlled preview)
previewState"loading" | "playing"Default playing
previewProgressnumber0 to 1, drives the ring
onPreview, onStopPreview(id) => void, () => voidControl playback yourself
previewError{ id, message? }A failed sample. The message should say how to fix it
loading, disabledboolean
labelstringAccessible name of the radio group. Default Voice
emptyMessageReactNodeShown with no voices
open, defaultOpen, onOpenChangebooleanPopover state
side, alignPopover placement

Keyboard

KeyAction
TabThe chosen voice, then each preview button
Arrow keysMove to the next or previous voice and choose it, skipping disabled ones
Space, EnterChoose the focused voice. In the popover, Enter also closes it
Enter, Space on a previewPlay or stop the sample
EscClose the popover and stop the sample

Accessibility

  • The list is a radiogroup with 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.