Voice Picker
Pick the TTS voice a reply is spoken in — cards or a combobox, language and accent chips, neutral timbre tags, and a play button that auditions without selecting, with exactly one sample sounding at a time.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/voice-picker.jsonPrompt
Build a React + TypeScript + Tailwind "VoicePicker" component with zod and
lucide-react. No audio library: one native <audio> element does the whole job.
Contract
- A zod schema (`voicePickerItemSchema` in a sibling contract file) is the single
source of truth for ONE voice: { id, name, lang (BCP-47), accent (human
words), tags: string[] (neutral timbre descriptors — "warm", "crisp",
"calm"), previewUrl?: string, description?: string }.
- `previewUrl` is optional on purpose: a catalogue always contains a voice whose
sample has not been rendered yet, and the picker must say "no sample" rather
than ship a play button that does nothing. Any URL a media element accepts
works, including `data:` and `blob:` — that is how you feed it audio you just
synthesised.
- Deliberately NOT in the contract: gender. A synthetic voice does not have one,
provider labels disagree, and the thing a listener actually compares is how it
SOUNDS. `tags` carries that.
- An envelope status — "loading" | "empty" | "error" | "ready" — is the state of
the CATALOGUE, independent of playback: a sample can fail while the list is
perfectly ready, and those two failures read differently on screen.
- Props: items; status; value?/defaultValue?/onValueChange?(id, voice) (the
usual controlled/uncontrolled pair, `null` = nothing chosen);
variant?: "grid" | "dropdown"; columns?: 1|2|3 (grid only); previewingId? +
previewPhase?: "loading"|"playing" + onPreviewChange?(id|null, voice|null);
maxTags? (3, clamped >= 1); formatLanguage?(voice); placeholder?; emptyState?;
emptyMessage?; errorMessage?; onRetry?; skeletonCount? (4, clamped >= 1);
label? ("Speaking voice"); className plus the rest of the div props, ref
forwarded to the root.
Behavior
- AUDITIONING IS NOT CHOOSING. The play button never commits a voice: it is a
real <button> that is a SIBLING of the selection element, and its click handler
stops propagation. A picker where hearing a voice also picks it makes
comparison impossible — you cannot listen to option three while option two is
still your choice.
- ONE PREVIEW AT A TIME, structurally. State is a single `{ id, phase }` slot and
a single <audio> element, never a per-card `isPlaying` flag. Starting a preview
pauses the element, swaps `src` and plays — so the previous voice stops because
there is nowhere for it to keep running, not because five booleans agreed to
reset. Every path that ends a preview (stop button, sample ended, failure, the
voice disappearing from `items`) does the same one thing: set the slot to null.
A single effect watches for null and silences the element.
- The phase flips to "playing" on the element's `playing` EVENT, not when
`play()`'s promise resolves: the promise only means playback was permitted, the
event means sound is actually coming out. Until then the button shows a
spinner, because a play button that looks idle for 400 ms of buffering gets
pressed twice.
- Stale playbacks cannot paint. Each start bumps a token held in a ref; a
rejected `play()` compares the token before reporting anything, so the
AbortError produced by the pause that started the NEXT preview is discarded
instead of drawing a failure over the voice that is currently sounding.
- Failures are attributed and readable: NotAllowedError ("your browser blocked
playback"), NotSupportedError ("this format can't be played here"), plus the
element's own `error` event for a sample that 404s or fails to decode. The
message renders under that voice and is announced; the failed URL is dropped
from the "already loaded" marker so pressing play again is a real retry rather
than a replay of a poisoned element.
- Controlled preview: pass `previewingId` (even as null) and the component stops
owning audio entirely — its element is never handed a source, nothing is
played, an internally-sounding voice is dropped the moment ownership changes,
and clicks only report intent through `onPreviewChange`. That is the seam for a
streaming TTS engine, a shared page-level player or a Web Audio graph.
`previewPhase` then drives the spinner-vs-waveform.
- Selection has two ARIA models for one contract. `grid` is a radiogroup with
roving tabindex: Tab lands on the chosen card, arrows WRAP and move the choice
(radio semantics), Home/End jump. `dropdown` is a combobox + listbox with
aria-activedescendant: arrows CLAMP at both ends (APG — a wrapping list makes
"am I at the bottom?" unanswerable), Enter commits and closes, Escape closes
without writing, Tab closes and moves on.
- A radio must not contain focusable descendants, so the card's radio is ONE
stretched transparent <button role="radio"> covering the card, with the play
button as its sibling; the visible content is aria-hidden and the radio's
aria-label is the whole card as a sentence (name, language, accent, every tag
including the ones folded into "+N", and "no sample available").
- The same rule bites harder in the listbox, where an option may not hold a
button at all: there the row's play glyph is a pointer-only affordance and the
keyboard path is the P key on the combobox (and on a focused card in the grid),
which auditions the highlighted row WITHOUT closing the panel and without
selecting it. P is ignored with Ctrl/Cmd/Alt held so it never eats Cmd+P.
- Two sr-only live regions, not one: what is SELECTED and what is SOUNDING are
different facts, and folding them into one string re-announces the choice every
time a sample ends.
- A committed id that is not in `items` is surfaced ("that voice isn't in this
catalogue"), never silently reset — a voice that vanished with a plan change is
something the user has to act on.
- Cleanup is explicit, and the media element is mounted in EVERY branch —
envelopes included — because detaching a media node does NOT reliably stop it:
the resource outlives the node and keeps talking over whatever renders next. So
a catalogue that flips to "loading" mid-playback silences the element first
instead of orphaning it, and on unmount the element is paused, its `src`
removed and `load()` called. The placement rAF is cancelled and the
outside-pointerdown listener removed.
Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
border-primary + ring-primary + bg-primary/5 for the chosen card, bg-primary
for the waveform bars, bg-accent/40 for hover, text-destructive with
border-destructive/40 + bg-destructive/5 for the catalogue failure. No
hard-coded colours anywhere.
- The waveform is five bars with per-bar duration and delay set inline (the
keyframes ride a hoisted <style href precedence> so the component stays one
droppable file). Each bar has a base height, so `motion-reduce:animate-none`
freezes them into a static equaliser glyph: the motion goes, the meaning
("this one is sounding") stays. Every transition and the loading spinner are
motion-reduce-guarded too.
- Long names use `wrap-anywhere` plus `min-w-0`, NOT `break-words`: only
overflow-wrap:anywhere lowers the min-content width, which is what actually
stops a 51-character provider name from widening the whole grid. The grid's
columns are container queries (@xl/@4xl), so a picker in a 380px settings
drawer reflows on ITS width, not the viewport's.
- Play / pause / spinner / muted-speaker are four distinct SHAPES, not four
colours, so the state survives a monochrome theme and a colour-blind reader.
Both the play control and the empty-sample state use aria-disabled rather than
the native attribute, which would drop the one element that explains why out of
the tab order.
- cn() merges the consumer's className into the root, which also spreads the
remaining div props and forwards its ref.
Customization levers
- Density and shape: `variant` picks the ARIA model ("grid" for comparison,
"dropdown" for a toolbar), `columns` sets the card tracks, `maxTags` decides
how many timbre words survive before "+N".
- Wording: `formatLanguage` turns the raw BCP-47 tag into whatever your audience
reads — `Intl.DisplayNames` is the obvious upgrade, but resolve it client-side
or SSR and the browser can disagree and hydrate mismatched. `placeholder`,
`label`, `emptyMessage`, `errorMessage` and `emptyState` own the rest of the
copy.
- Who owns the sound: leave `previewingId` off and the built-in element handles
it; pass it and you own playback (streaming TTS, one shared player for the
page, an offline cache) while the picker keeps the lock semantics.
- Failure envelope: `onRetry` renders Try again; `errorMessage` carries the
request id on its own line so a support ticket gets a usable copy.
- Motion: drop the waveform entirely by rendering nothing for the sounding state
— the play/pause shape already carries it — or raise the bar count and heights
for a chunkier meter. Do not swap the base heights for a pure opacity pulse:
that is the part that disappears under prefers-reduced-motion.Concepts
- Auditioning is not choosing — the play button is a sibling of the selection element, not a child of it, and it stops propagation. That one rule is what lets you listen to the fourth voice while the second is still your answer; a picker that commits on play forces you to overwrite your choice just to compare it.
- The one-at-a-time lock is structural — there is a single
{ id, phase }slot and a single<audio>element, so starting a preview stops the previous one because there is nowhere left for it to run. Nothing has to remember to reset a neighbour'sisPlaying, which is the bug every per-card boolean eventually ships. - "Playing" is an event, not a resolved promise —
play()resolving only means playback was permitted; the element'splayingevent means sound is coming out. Holding the spinner until then is what stops a buffering sample from being pressed twice. - A stale playback must not paint — pausing to start the next preview rejects the previous
play()with an AbortError. A token stored in a ref, compared inside the rejection handler, is what keeps that dead request from drawing a failure over the voice that is currently sounding. - Controlled preview = you own the sound — pass
previewingIdand the built-in element is never handed a source; the picker becomes a renderer of "who is sounding" plus an intent reporter. Streaming TTS, a shared page player and an offline cache all plug in there without forking the component. - Reduced motion drops the movement, keeps the meaning — the waveform bars carry their own base heights, so freezing the animation leaves a static equaliser glyph rather than a blank gap; the state stays legible, only the motion goes.
Live Captions
A broadcast-style caption bar for a live recogniser feed — interim words render muted and solidify when finalised, a rolling two-line window follows the speech, speaker chips carry the turn, and the panel hides itself after silence.
Model Card
One model's spec sheet — context window, max output and cutoff, tri-state capability chips, per-million-token pricing with the cached-read discount, benchmark bars that switch to a signed comparison against a baseline, and a deprecation notice that names the successor.