Skip to content

shadcn.io is an independent project built on shadcn/ui

Components

Free

Voice Selector for React

A searchable dialog of text-to-speech voices with a play button on each. For React with Base UI, with the gender, accent and age on every row.

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.

Preview width

Installation

Sign in to get your install command
Free with an account. It carries your token, so the CLI installs everything in one go.

Anatomy

Voice Selector anatomy: content, input, item, preview, name, attributes
VoiceSelector
├── VoiceSelectorTrigger
└── VoiceSelectorContent
    ├── VoiceSelectorInput
    └── VoiceSelectorList
        ├── VoiceSelectorEmpty
        ├── VoiceSelectorGroup
        │   └── VoiceSelectorItem (one per voice)
        │       ├── VoiceSelectorPreview
        │       ├── VoiceSelectorName
        │       ├── VoiceSelectorDescription
        │       ├── VoiceSelectorAttributes
        │       │   ├── VoiceSelectorGender
        │       │   ├── VoiceSelectorBullet
        │       │   ├── VoiceSelectorAccent
        │       │   └── VoiceSelectorAge
        │       └── VoiceSelectorShortcut
        └── VoiceSelectorSeparator

Picking 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 { Button } from "@/components/ui/button";
import {
  VoiceSelector, VoiceSelectorAccent, VoiceSelectorAttributes,
  VoiceSelectorContent, VoiceSelectorDescription, VoiceSelectorEmpty,
  VoiceSelectorGender, VoiceSelectorGroup, VoiceSelectorInput,
  VoiceSelectorItem, VoiceSelectorList, VoiceSelectorName,
  VoiceSelectorPreview, VoiceSelectorTrigger,
} from "@/components/ui/voice-selector";
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
          render={
            <Button 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>
            <VoiceSelectorEmpty>No voices found.</VoiceSelectorEmpty>
            <VoiceSelectorGroup heading="Available voices">
              {voices.map((item) => (
                <VoiceSelectorItem
                  key={item.value}
                  keywords={[item.label, item.description]}
                  value={item.value}
                >
                  <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 width

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.

Preview width

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.

Preview width

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.

Preview width

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 (opens in a new tab), install this one and change the import from @/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's title is a string, and it takes a description for screen readers too.
  • VoiceSelectorAccent knows 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 Base UI, VoiceSelectorTrigger takes your button through render instead of asChild.

FAQ

How do I install Voice Selector in React?

Run the command in the Installation section on this page. It adds voice-selector to your ui folder with the Dialog, Command and Button it's built from, and lucide for the icons. It plays nothing itself, so there's no audio package. The command uses the shadcn CLI.

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.

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

PropType
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
actionsRef?React.RefObject<DialogRoot.Actions | null>

A ref to imperative actions. - unmount: Ends the closing phase of the dialog after an externally controlled closing animation finishes. Call preventUnmountOnClose() in onOpenChange first, otherwise the dialog completes closing on its own. Whether it leaves the DOM is decided by keepMounted on the portal. - close: Closes the dialog imperatively when called.

Type
React.RefObject<DialogRoot.Actions | null>
children?React.ReactNode | PayloadChildRenderFunction<Payload>

The content of the dialog. This can be a regular React node or a render function that receives the payload of the active trigger.

Type
React.ReactNode | PayloadChildRenderFunction<Payload>
defaultTriggerId?string | null

ID of the trigger that the dialog is associated with. This is useful in conjunction with the defaultOpen prop to create an initially open dialog.

Type
string | null
disablePointerDismissal?boolean

Whether to prevent the dialog from closing on outside presses. For non-modal dialogs, this also prevents the dialog from closing when focus moves outside of it.

Default
false
handle?DialogHandle<Payload>

A handle to associate the dialog with a trigger. If specified, allows external triggers to control the dialog's open state. Can be created with the Dialog.createHandle() method.

Type
DialogHandle<Payload>
modal?boolean | "trap-focus"

Determines if the dialog enters a modal state when open. - true: user interaction is limited to just the dialog: focus is trapped, document page scroll is locked, and pointer interactions on outside elements are disabled. - false: user interaction with the rest of the document is allowed. - trap-focus: focus is trapped inside the dialog, but document page scroll is not locked and pointer interactions outside of it remain enabled. When modal is true or trap-focus, render <Dialog.Close> inside <Dialog.Popup> so touch screen readers can escape the popup.

Type
boolean | "trap-focus"
Default
true
onOpenChangeComplete?(open: boolean) → void

Event handler called after any animations complete when the dialog is opened or closed.

Parameters

open – boolean

Returns
void
triggerId?string | null

ID of the trigger that the dialog is associated with. This is useful in conjunction with the open prop to create a controlled dialog. There's no need to specify this prop when the dialog is uncontrolled (that is, when the open prop is not set).

Type
string | null

VoiceSelectorTrigger

data-slot="voice-selector-trigger"

PropType
handle?DialogHandle<Payload>

A handle to associate the trigger with a dialog. Can be created with the Dialog.createHandle() method.

Type
DialogHandle<Payload>
id?string

ID of the trigger. In addition to being forwarded to the rendered element, it is also used to specify the active trigger for the dialog in controlled mode (with the DialogRoot triggerId prop).

nativeButton?boolean

Whether the component renders a native <button> element when replacing it via the render prop. Set to false if the rendered element is not a button (for example, <div>).

Default
true
payload?Payload

A payload to pass to the dialog when it is opened.

render?ReactElement | ((props: object, state: object) => ReactElement)

Replace the host element or compose it with another component.

Type
ReactElement | ((props: object, state: object) => ReactElement)

VoiceSelectorContent

data-slot="voice-selector-content"

PropType
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."
positionerClassName?string

Class names on the positioner (the viewport-fixed wrapper).

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
title?string

The dialog's title, for screen readers.

Default
"Voice Selector"
render?ReactElement | ((props: object, state: object) => ReactElement)

Replace the host element or compose it with another component.

Type
ReactElement | ((props: object, state: object) => ReactElement)
className?string

Merged with the part's classes.

VoiceSelectorInput

data-slot="voice-selector-input"

PropType
asChild?boolean

Merge props onto the child element instead of rendering a host.

VoiceSelectorList

data-slot="voice-selector-list"

PropType
asChild?boolean

Merge props onto the child element instead of rendering a host.

className?string

Merged with the part's classes.

VoiceSelectorEmpty

data-slot="voice-selector-empty"

PropType
asChild?boolean

Merge props onto the child element instead of rendering a host.

VoiceSelectorGroup

data-slot="voice-selector-group"

PropType
asChild?boolean

Merge props onto the child element instead of rendering a host.

VoiceSelectorItem

data-slot="voice-selector-item"

PropType
asChild?boolean

Merge props onto the child element instead of rendering a host.

className?string

Merged with the part's classes.

AttributeType
data-checked?string

Mirrors checked.

VoiceSelectorShortcut

data-slot="voice-selector-shortcut"

VoiceSelectorSeparator

data-slot="voice-selector-separator"

PropType
asChild?boolean

Merge props onto the child element instead of rendering a host.

VoiceSelectorGender

<span> data-slot="voice-selector-gender"

PropType
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"

PropType
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"

PropType
className?string

Merged with the part's classes.

VoiceSelectorName

<span> data-slot="voice-selector-name"

PropType
className?string

Merged with the part's classes.

VoiceSelectorDescription

<span> data-slot="voice-selector-description"

PropType
className?string

Merged with the part's classes.

VoiceSelectorAttributes

<div> data-slot="voice-selector-attributes"

PropType
className?string

Merged with the part's classes.

VoiceSelectorBullet

<span> data-slot="voice-selector-bullet"

PropType
className?string

Merged with the part's classes.

VoiceSelectorPreview

data-slot="voice-selector-preview"

PropType
clickEffect?boolean

Apply a click effect to the button.

Default
true
focusableWhenDisabled?boolean

Whether the button should be focusable when disabled.

Default
false
isLoading?boolean

Show a loading indicator.

Default
false
loading?boolean

Shows a spinner and disables the button while the sample loads.

nativeButton?boolean

Whether the component renders a native <button> element when replacing it via the render prop. Set to false if the rendered element is not a button (for example, <div>).

Default
true
onPlay?() → void

Called on press. Play or pause the sample here.

Returns
void
pill?boolean

Fully rounded corners.

Default
false
playing?boolean

Shows pause instead of play while the sample plays.

size?"default" | "icon" | "icon-lg" | "icon-md" | "icon-sm" | "icon-xl" | "icon-xs" | "lg" | "md" | "sm" | "xl" | "xs"

The size.

Type
"default" | "icon" | "icon-lg" | "icon-md" | "icon-sm" | "icon-xl" | "icon-xs" | "lg" | "md" | "sm" | "xl" | "xs"
Default
"md"
variant?"default" | "destructive" | "ghost" | "link" | "outline" | "secondary"

The visual style.

Type
"default" | "destructive" | "ghost" | "link" | "outline" | "secondary"
Default
"default"
render?ReactElement | ((props: object, state: object) => ReactElement)

Replace the host element or compose it with another component.

Type
ReactElement | ((props: object, state: object) => ReactElement)
className?string

Merged with the part's classes.

useVoiceSelector