shadcn.io is an independent project built on shadcn/ui
A dialog of text-to-speech voices to search and pick from, each with a button that plays it first.
Use it where a person chooses how your assistant sounds: a settings row, the first run of a voice mode, a read-aloud app. Each voice shows its name, a line on how it sounds, and its gender, accent and age, so the list reads before anything plays. In React, it's a Dialog holding a Command: it hands you the picked voice's value, and the play button only reports the press. Playing the sample, and speaking with the voice, is yours.
Installation
Anatomy

VoiceSelector
├── VoiceSelectorTrigger
└── VoiceSelectorContent
├── VoiceSelectorInput
└── VoiceSelectorList
├── VoiceSelectorEmpty
├── VoiceSelectorGroup
│ └── VoiceSelectorItem (one per voice)
│ ├── VoiceSelectorPreview
│ ├── VoiceSelectorName
│ ├── VoiceSelectorDescription
│ ├── VoiceSelectorAttributes
│ │ ├── VoiceSelectorGender
│ │ ├── VoiceSelectorBullet
│ │ ├── VoiceSelectorAccent
│ │ └── VoiceSelectorAge
│ └── VoiceSelectorShortcut
└── VoiceSelectorSeparatorPicking a voice sets value and closes the dialog. useVoiceSelector reads the picked value and the open state from inside a VoiceSelector, for a part of your own.
Usage
import { ChevronsUpDownIcon } from "lucide-react";
import { useState } from "react";
import {
VoiceSelector, VoiceSelectorAccent, VoiceSelectorAttributes,
VoiceSelectorContent, VoiceSelectorDescription, VoiceSelectorEmpty,
VoiceSelectorGender, VoiceSelectorGroup, VoiceSelectorInput,
VoiceSelectorItem, VoiceSelectorList, VoiceSelectorName,
VoiceSelectorPreview, VoiceSelectorTrigger,
} from "@/components/ui/voice-selector";const renderEmpty = () => (
<VoiceSelectorEmpty>No voices found.</VoiceSelectorEmpty>
);
const voices = [
{
accent: "american",
description: "Warm and professional",
gender: "female" as const,
label: "Alloy",
value: "alloy",
},
{
accent: "british",
description: "Clear and articulate",
gender: "male" as const,
label: "Echo",
value: "echo",
},
{
accent: "australian",
description: "Friendly and energetic",
gender: "female" as const,
label: "Nova",
value: "nova",
},
{
accent: "american",
description: "Deep and authoritative",
gender: "male" as const,
label: "Onyx",
value: "onyx",
},
];
export default function VoiceSelectorDemo() {
const [voice, setVoice] = useState<string>();
const [playing, setPlaying] = useState<string | null>(null);
const selected = voices.find((item) => item.value === voice);
const handlePlay = (value: string) => {
setPlaying(playing === value ? null : value);
};
return (
<div className="w-full max-w-xs">
<VoiceSelector onValueChange={setVoice} value={voice}>
<VoiceSelectorTrigger
className="w-full justify-between"
variant="outline"
>
{selected ? selected.label : "Select a voice..."}
<ChevronsUpDownIcon
aria-hidden="true"
className="text-muted-foreground"
/>
</VoiceSelectorTrigger>
<VoiceSelectorContent>
<VoiceSelectorInput placeholder="Search voices..." />
<VoiceSelectorList renderEmptyState={renderEmpty}>
<VoiceSelectorGroup heading="Available voices">
{voices.map((item) => (
<VoiceSelectorItem
id={item.value}
key={item.value}
textValue={item.label}
>
<VoiceSelectorPreview
onPlay={() => handlePlay(item.value)}
playing={playing === item.value}
/>
<div className="flex min-w-0 flex-1 flex-col">
<VoiceSelectorName>{item.label}</VoiceSelectorName>
<VoiceSelectorDescription>
{item.description}
</VoiceSelectorDescription>
</div>
<VoiceSelectorAttributes className="gap-2">
<VoiceSelectorGender value={item.gender} />
<VoiceSelectorAccent value={item.accent} />
</VoiceSelectorAttributes>
</VoiceSelectorItem>
))}
</VoiceSelectorGroup>
</VoiceSelectorList>
</VoiceSelectorContent>
</VoiceSelector>
</div>
);
}
Demos
Attributes
Six voices from two providers in groups, a separator between them. Under each name, VoiceSelectorAttributes lines up the voice's gender, accent and age with a word on its tone, VoiceSelectorBullet between each. Use it when the list is long enough that people choose by trait before they listen. VoiceSelectorGender draws one of six symbols and VoiceSelectorAccent a flag, each with its name for screen readers.
Preview
Each VoiceSelectorPreview speaks a line in the browser's own speech synthesis, at the voice's pitch and rate. It shows a spinner until the speech starts, pause while it plays, and play again when it ends; pressing it never picks the voice. Swap the synthesis for an <audio> element and each voice's sample URL to play a provider's real voices. Stop the sample when the dialog closes.
Shortcuts
VoiceSelectorShortcut at each row's end, from Alt+1 to Alt+4, and a key listener that picks the voice while the dialog is open. The shortcut is a hint: binding the keys is yours. Use it for a short list people switch between often, and keep the picked voice's check beside it.
Controlled
A settings row that holds open and value itself, with a Change button that opens the dialog and a line that shows the picked voice. Control open to open the dialog from somewhere other than a trigger, such as a menu item or the first run of a voice mode.
AI SDK
The picked value is the voice the speech model speaks with. Send it with the text to a route that calls generateSpeech, and play the audio that comes back:
import { openai } from "@ai-sdk/openai";
import { generateSpeech } from "ai";
export async function POST(request: Request) {
const { text, voice } = await request.json();
const { audio } = await generateSpeech({
model: openai.speech("gpt-4o-mini-tts"),
text,
voice,
});
return new Response(audio.uint8Array, {
headers: { "Content-Type": audio.mediaType },
});
}The same route can make each voice's preview: call it once per voice with a short line, and keep the files rather than paying for a sample on every press.
Migrating from AI SDK Elements
If you added Voice Selector from AI SDK Elements@/components/ai-elements/voice-selector to @/components/ui/voice-selector. Every part keeps its name, and value, defaultValue, onValueChange, open, defaultOpen, onOpenChange and the preview's playing, loading and onPlay work the same. What differs:
VoiceSelectorContent'stitleis a string, and it takes adescriptionfor screen readers too.VoiceSelectorAccentknows about 35 accents and reads each one's name to screen readers; an accent it doesn't know shows its name.- The picked voice shows a check, and a preview button doesn't pick the voice with Enter or Space either.
On React Aria, VoiceSelectorTrigger is the Button itself, VoiceSelectorItem takes id and textValue instead of value, and VoiceSelectorEmpty goes in VoiceSelectorList's renderEmptyState.
FAQ
How does the play button in Voice Selector play a voice?
It doesn't: VoiceSelectorPreview calls onPlay and you play the sample, then set loading while it loads and playing while it plays. The Preview demo speaks with the browser's speech synthesis; an <audio> element and a sample URL per voice play a provider's real voices.
How do I get the voice someone picks in Voice Selector?
onValueChange gives you its value, and picking closes the dialog. Pass value to control it, or defaultValue to start from one, and send the value to your speech model, as the AI SDK section does. The picked voice shows a check in the list.
What does the search in Voice Selector match?
Each item's textValue. Put the name in it, and any word people search by, such as the accent.
Which genders and accents can Voice Selector show?
VoiceSelectorGender draws a symbol for male, female, transgender, androgyne, non-binary and intersex, and a small circle for anything else. VoiceSelectorAccent shows a flag for 35 accents, from american to vietnamese. Both read the name to screen readers, and children replace the drawing.
How do I open Voice Selector from my own button?
Control it: hold open in your state, set it from the button and update it from onOpenChange, as the Controlled demo does. The dialog still closes on Escape, a click outside, or a pick, and tells you through onOpenChange.
Can Voice Selector be used with the keyboard?
Yes. The trigger opens the dialog with the search focused; typing filters, the arrow keys move through the voices, Enter picks one and Escape closes. Tab reaches the play buttons, where Enter and Space play the sample without picking the voice.
Is this the same as AI SDK Elements' Voice Selector?
It has the same parts and props, so most code only changes its import. The title also takes a description, the accents read their names aloud, and the picked voice shows a check. The Migrating section lists what differs on your stack.
API Reference
VoiceSelector
open?boolean
Whether the dialog is open, when controlled.
defaultOpen?boolean
Whether the dialog starts open, when uncontrolled.
- Default
false
onOpenChange?(open: boolean) → void
Called when the dialog opens or closes.
- Parameters
open– boolean- Returns
void
value?string
The picked voice's value, when controlled.
defaultValue?string
The voice picked at first, when uncontrolled.
onValueChange?(value: string) → void
Called with a voice's value when one is picked.
- Parameters
value– string- Returns
void
children?React.ReactNode
The trigger and the content.
VoiceSelectorTrigger
data-slot="voice-selector-trigger"
children?ReactNode
The content to display in the button.
clickEffect?boolean
Apply a click effect to the button.
- Default
true
download?LinkPrimitiveProps["download"]
With href, downloads the linked file instead of opening it.
href?string
When set, the button renders as a link.
isDisabled?boolean
Whether the button is disabled.
isLoading?boolean
Show a loading indicator.
- Default
false
rel?string
With href, the link's relationship to the page (noreferrer, …).
target?string
With href, where the linked page opens (_blank, …).
className?string
Merged with the part's classes.
VoiceSelectorContent
data-slot="voice-selector-content"
defaultOpen?boolean
Whether the overlay is open by default (uncontrolled).
onOpenChange?(isOpen: boolean) → void
Handler that is called when the overlay's open state changes.
- Parameters
isOpen– boolean- Returns
void
bottomStickOnMobile?boolean
Stick the dialog to the bottom of the screen on mobile. Ark default: true.
- Default
false
description?string
The dialog's description, for screen readers.
- Default
"Search for a voice and pick one."
isDismissable?boolean
Whether to close the modal when the user interacts outside it.
- Default
false
isEntering?boolean
Whether the modal is currently performing an entry animation.
isExiting?boolean
Whether the modal is currently performing an exit animation.
isKeyboardDismissDisabled?boolean
Whether pressing the escape key to close the modal should be disabled.
- Default
false
isOpen?boolean
Whether the overlay is open by default (controlled).
onEnter?(element: HTMLElement) → void | Promise<void>
Callback when the modal starts entering. Return a promise to indicate when the entry animation has completed.
- Type
(element: HTMLElement) → void | Promise<void>- Parameters
element– HTMLElement- Returns
void | Promise<void>
onExit?(element: HTMLElement) → void | Promise<void>
Callback when the modal starts exiting. Return a promise to indicate when the exit animation has completed.
- Type
(element: HTMLElement) → void | Promise<void>- Parameters
element– HTMLElement- Returns
void | Promise<void>
role?"dialog" | "alertdialog"
The accessibility role for the dialog.
- Type
"dialog" | "alertdialog"- Default
"dialog"
shouldCloseOnInteractOutside?(element: Element) → boolean
When user interacts with the argument element outside of the overlay ref, return true if onClose should be called. This gives you a chance to filter out interaction with elements that should not dismiss the overlay. By default, onClose will always be called on interaction outside the overlay ref.
- Parameters
element– Element- Returns
boolean
showCloseButton?boolean
Show close button at the top right corner. Ark default: true.
- Default
false
size?"2xl" | "3xl" | "4xl" | "5xl" | "6xl" | "fullscreen" | "lg" | "md" | "sm" | "xl"
Ark default: md.
- Type
"2xl" | "3xl" | "4xl" | "5xl" | "6xl" | "fullscreen" | "lg" | "md" | "sm" | "xl"- Default
lg
slot?string | null
A slot name for the component. Slots allow the component to receive props from a parent
component. An explicit null value indicates that the local props completely override all
props received from a parent.
- Type
string | null- Default
voice-selector-content
style?StyleOrFunction<T>
The inline [style](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style) for the element. A function may be provided to compute the style based on component state.
- Type
StyleOrFunction<T>
title?string
The dialog's title, for screen readers.
- Default
"Voice Selector"
UNSTABLE_deferUntilEntered?boolean
Whether to wait until the entry animation completes before hiding content outside the modal and moving focus into it.
UNSTABLE_overrideFocus?boolean
Whether to override the focus() method so it does not scroll the page.
This is temporary and will be removed after fully testing all overlays.
UNSTABLE_portalContainer?Element
The container element in which the overlay portal will be placed. This may have unknown behavior depending on where it is portalled to. Deprecated. - Use a parent UNSAFE_PortalProvider to set your portal container instead.
- Default
document.body
render?DOMRenderFunction<E, T>
Overrides the default DOM element with a custom render function.
This allows rendering existing components with built-in styles and behaviors
such as router links, animation libraries, and pre-styled components.
Requirements:
- You must render the expected element type (e.g. if <button> is expected, you cannot render an
<a>).
- Only a single root DOM element can be rendered (no fragments).
- You must pass through props and ref to the underlying DOM element, merging with your own prop
as appropriate.
- Type
DOMRenderFunction<E, T>
className?string
Merged with the part's classes.
VoiceSelectorInput
data-slot="voice-selector-input"
children?ReactNode
The children of the component.
enterKeyHint?"enter" | "done" | "go" | "next" | "previous" | "search" | "send"
An enumerated attribute that defines what action label or icon to preset for the enter key on virtual keyboards. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/enterkeyhint).
- Type
"enter" | "done" | "go" | "next" | "previous" | "search" | "send"
style?object
The inline [style](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style) for the element.
- Type
{ prop?: string]: string | number }
className?string
The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the element.
VoiceSelectorList
data-slot="voice-selector-list"
className?string
Merged with the part's classes.
VoiceSelectorEmpty
data-slot="voice-selector-empty"
VoiceSelectorGroup
data-slot="voice-selector-group"
children?ReactNode | ((item: T) → ReactElement)
Static child items or a function to render children.
- Type
ReactNode | ((item: T) → ReactElement)
dependencies?ReadonlyArray<any>
Values that should invalidate the item cache when using dynamic collections.
- Type
ReadonlyArray<any>
heading?string
A label shown above the group's items.
id?Key
The unique id of the section.
shouldCloseOnSelect?boolean
Whether the menu should close when the menu item is selected.
style?object
The inline [style](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style) for the element.
- Type
{ prop?: string]: string | number }
value?T
The object value that this section represents. When using dynamic collections, this is set automatically.
render?DOMRenderFunction<E, T>
Overrides the default DOM element with a custom render function.
This allows rendering existing components with built-in styles and behaviors
such as router links, animation libraries, and pre-styled components.
Requirements:
- You must render the expected element type (e.g. if <button> is expected, you cannot render an
<a>).
- Only a single root DOM element can be rendered (no fragments).
- You must pass through props and ref to the underlying DOM element, merging with your own prop
as appropriate.
- Type
DOMRenderFunction<E, T>
className?string
The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the element.
- Default
"react-aria-MenuSection"
VoiceSelectorItem
data-slot="voice-selector-item"
className?string
Merged with the part's classes.
data-checked?string
Mirrors checked.
VoiceSelectorShortcut
data-slot="voice-selector-shortcut"
VoiceSelectorSeparator
data-slot="voice-selector-separator"
children?ReactNode
The children of the component.
elementType?string
The HTML element type that will be used to render the separator.
orientation?"horizontal" | "vertical"
The orientation of the separator.
- Type
"horizontal" | "vertical"- Default
"horizontal"
style?object
The inline [style](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style) for the element.
- Type
{ prop?: string]: string | number }
className?string
The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the element.
VoiceSelectorGender
<span> data-slot="voice-selector-gender"
value?VoiceSelectorGenderValue
The voice's gender, drawn as its symbol and read out as its name.
className?string
Merged with the part's classes.
VoiceSelectorAccent
<span> data-slot="voice-selector-accent"
value?string
The accent, such as british: shown as its country's flag.
className?string
Merged with the part's classes.
VoiceSelectorAge
<span> data-slot="voice-selector-age"
className?string
Merged with the part's classes.
VoiceSelectorName
<span> data-slot="voice-selector-name"
className?string
Merged with the part's classes.
VoiceSelectorDescription
<span> data-slot="voice-selector-description"
className?string
Merged with the part's classes.
VoiceSelectorAttributes
<div> data-slot="voice-selector-attributes"
className?string
Merged with the part's classes.
VoiceSelectorBullet
<span> data-slot="voice-selector-bullet"
className?string
Merged with the part's classes.
VoiceSelectorPreview
data-slot="voice-selector-preview"
isDisabled?boolean
Whether the button is disabled.
loading?boolean
Shows a spinner and disables the button while the sample loads.
onPlay?() → void
Called on press. Play or pause the sample here.
- Returns
void
playing?boolean
Shows pause instead of play while the sample plays.
className?string
Merged with the part's classes.












