Voice Selector
The TTS voice picker (UC-VOICE-02 AC-2). Port-adapted per the PRD verdict: a bottom sheet whose voice set comes from the caller's native speech synthesis provider — no audio dependency in the registry, preview playback is a caller callback reported back through previewingId/previewLoadingId. The PRD composition law is structural: the preview button lives in the row's action slot, so pressing it PLAYS and NEVER selects; three states (play, playing, loading-disabled), disabled-when-unwired. Upstream traps at byte parity: useVoiceSelector throws outside the root, the accent lookup is CASE-SENSITIVE ('AMERICAN' renders no flag), unknown genders fall back, explicit empty state. Shortcut and dialog parts dropped (no ⌘ on a phone; the sheet is the presentation).
- v0.2.1
- component
- nativewind
- uniwind
- runs in Expo Go
Install
npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/voice-selector.jsonnpx @react-native-reusables/cli@latest add https://rnraielements.com/r/uniwind/voice-selector.jsonDependencies
react-native-reanimated
https://reactnativereusables.com/r/nativewind/button.json |
https://reactnativereusables.com/r/nativewind/icon.json |
https://reactnativereusables.com/r/nativewind/text.json |
https://rnraielements.com/r/nativewind/command.json |
https://rnraielements.com/r/nativewind/item.json |
https://reactnativereusables.com/r/uniwind/button.json |
https://reactnativereusables.com/r/uniwind/icon.json |
https://reactnativereusables.com/r/uniwind/text.json |
https://rnraielements.com/r/uniwind/command.json |
https://rnraielements.com/r/uniwind/item.json |
AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92
Source
import { Button } from '@/registry/nativewind/components/ui/button';
import { Command } from '@/registry/nativewind/components/ui/command';
import { Icon } from '@/registry/nativewind/components/ui/icon';
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemTitle,
} from '@/registry/nativewind/components/ui/item';
import { Text } from '@/registry/nativewind/components/ui/text';
import { cn } from '@/registry/nativewind/lib/utils';
import {
AudioLinesIcon,
ChevronsUpDownIcon,
CircleSmallIcon,
LoaderCircleIcon,
MarsStrokeIcon,
NonBinaryIcon,
PauseIcon,
PlayIcon,
TransgenderIcon,
VenusAndMarsIcon,
VenusIcon,
MarsIcon,
type LucideIcon,
} from 'lucide-react-native';
import * as React from 'react';
import { View, type ViewProps } from 'react-native';
import Animated, {
cancelAnimation,
Easing,
ReduceMotion,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from 'react-native-reanimated';
import {
resolveVoiceGender,
voiceAccentFlag,
voicePreviewState,
type VoiceSelectorVoice,
} from './voice-selector.logic';
import type { CommandItem } from '../ui/command.logic';
/**
* VoiceSelector — the TTS voice picker (UC-VOICE-02 AC-2: "select a text-to-speech
* voice from a bottom-sheet list showing each voice name and language").
*
* THE PRD VERDICT IS PORT-ADAPTED, PURE-RNR: "Voice list ports, but the picker
* becomes a bottom sheet and the available voice set comes from the native speech
* synthesis provider rather than the browser." The set is therefore CALLER-SUPPLIED
* (expo-speech's getVoices(), a provider roster) — the browser's voice enumeration
* has no RN equivalent, and the registry takes no audio dependency. Preview playback
* is a caller callback too: onPreview(voice) fires, the component only DISPLAYS the
* state you report back.
*
* THE COMPOSITION LAW (the PRD's own words): "tapping the preview button PLAYS the
* sample and must NOT select the voice. Two nested targets, two distinct outcomes."
* Realized structurally: the preview Button lives in the row's ItemActions, and React
* Native's responder system gives the touch to the DEEPEST pressable — the row's
* onPress never fires when the preview is the target. The preview target is RNR's
* h-10 icon Button + hitSlop (the 44pt house formula), visually separated in the
* row's action slot per the KB's mistap advice. THE ROW-PRESS-DOES-NOT-FIRE HALF IS
* A DEVICE-TIER CLAIM — verify it on the sign-off run.
*
* PREVIEW STATES are the web's three: idle (play mark, "Play preview"), playing
* (pause mark, "Pause preview"), loading (disabled). Report them with
* previewingId / previewLoadingId. With NO onPreview wired the control DISABLES
* rather than disappears (speech-input: a control that pretends to work is the lie).
*
* THE UPSTREAM TRAPS, PRESERVED AT BYTE PARITY:
* - useVoiceSelector() outside <VoiceSelector> throws
* 'VoiceSelector components must be used within VoiceSelector'.
* - The accent lookup is CASE-SENSITIVE — "AMERICAN" renders no flag (voice-selector.logic).
* - Unknown genders fall back to a default mark rather than throwing.
* - Type-to-filter with an explicit empty state (the command atom's).
*
* DROPPED ON THE RECORD: VoiceSelectorShortcut (⌘ hints — no ⌘ on a phone, PRD port
* advice) and VoiceSelectorDialog (the ⌘K dialog variant — the bottom sheet IS the
* presentation). Input/List/Item/Empty are absorbed into the command atom; rows are
* data-driven because the list is a FlatList (the KB: "a voice picker can carry
* hundreds of entries").
*
* Composition:
* <VoiceSelector voices={VOICES} value={v} onValueChange={setV} onPreview={play}
* previewingId={playingId} previewLoadingId={loadingId}>
* <VoiceSelectorTrigger />
* </VoiceSelector>
*/
// The web's full mark set (voice-selector.tsx:205-229), at the web's size-4 (16px);
// omitted or unknown input falls to the default mark rather than throwing.
const genderIcons: Record<string, LucideIcon> = {
male: MarsIcon,
female: VenusIcon,
transgender: TransgenderIcon,
androgyne: MarsStrokeIcon,
'non-binary': NonBinaryIcon,
intersex: VenusAndMarsIcon,
unknown: CircleSmallIcon,
};
type VoiceSelectorContextValue = {
value: string | undefined;
setValue: (value: string) => void;
open: boolean;
setOpen: (open: boolean) => void;
/** The roster, so the default trigger can resolve an id to its NAME. */
voices: readonly VoiceSelectorVoice[];
};
const VoiceSelectorContext = React.createContext<VoiceSelectorContextValue | null>(null);
/**
* The web hook, byte-shape ({ value, setValue, open, setOpen }) and byte-trap
* (throws outside the provider). setValue notifies onValueChange WITHOUT closing —
* the row press is what selects-and-closes, the hook is the programmatic seam.
*/
function useVoiceSelector(): VoiceSelectorContextValue {
const ctx = React.useContext(VoiceSelectorContext);
if (!ctx) {
throw new Error('VoiceSelector components must be used within VoiceSelector');
}
return ctx;
}
type VoiceSelectorProps = {
/** The voices YOUR provider offers — native TTS or a hosted roster. */
voices: readonly VoiceSelectorVoice[];
value: string | undefined;
onValueChange: (value: string) => void;
/** Play this voice's sample. Absent → every preview control DISABLES, never fakes. */
onPreview?: (voice: VoiceSelectorVoice) => void;
/** The voice whose sample is currently playing (pause mark on that row). */
previewingId?: string;
/** The voice whose sample is loading (disabled control on that row). */
previewLoadingId?: string;
open?: boolean;
/** The web contract ships defaultOpen; the sheet opens closed without it. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
placeholder?: string;
emptyTitle?: string;
emptyDescription?: string;
/** The sheet's screen-reader title — the web Content's `title` prop. */
title?: string;
/** Passed to the sheet content — height and padding adjustments. */
className?: string;
children?: React.ReactNode;
};
function VoiceSelector({
voices,
value,
onValueChange,
onPreview,
previewingId,
previewLoadingId,
open: openProp,
defaultOpen = false,
onOpenChange,
placeholder = 'Search voices…',
emptyTitle = 'No voices found',
emptyDescription = 'Try a different search.',
title = 'Select AI Voice',
className,
children,
}: VoiceSelectorProps) {
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
const open = openProp ?? internalOpen;
const setOpen = React.useCallback(
(next: boolean) => {
if (openProp === undefined) setInternalOpen(next);
onOpenChange?.(next);
},
[openProp, onOpenChange],
);
const setValue = React.useCallback(
(next: string) => {
onValueChange(next);
},
[onValueChange],
);
const items = React.useMemo(
() =>
voices.map((v) => ({
value: v.id,
label: v.name,
description: v.description,
keywords: [v.accent, v.age, v.keywords].filter(Boolean).join(' ') || undefined,
group: v.group,
})),
[voices],
);
const voicesById = React.useMemo(
() => new Map(voices.map((v) => [v.id, v])),
[voices],
);
function renderRow({ item, selected }: { item: CommandItem; selected: boolean }) {
const voice = voicesById.get(item.value);
if (!voice) return null;
return (
<Item
variant={selected ? 'muted' : 'default'}
onPress={() => {
onValueChange(voice.id);
setOpen(false);
}}
>
<ItemContent>
<VoiceSelectorName>{voice.name}</VoiceSelectorName>
<VoiceSelectorAttributes voice={voice} />
{voice.description ? (
<VoiceSelectorDescription>{voice.description}</VoiceSelectorDescription>
) : null}
</ItemContent>
<ItemActions>
<VoiceSelectorPreview
playing={voicePreviewState(voice.id, previewingId, previewLoadingId) === 'playing'}
loading={voicePreviewState(voice.id, previewingId, previewLoadingId) === 'loading'}
onPlay={onPreview ? () => onPreview(voice) : undefined}
/>
</ItemActions>
</Item>
);
}
const contextValue = React.useMemo<VoiceSelectorContextValue>(
() => ({ value, setValue, open, setOpen, voices }),
[value, setValue, open, setOpen, voices],
);
return (
<VoiceSelectorContext.Provider value={contextValue}>
<Command
open={open}
onOpenChange={setOpen}
items={items}
value={value}
onSelect={(next) => {
onValueChange(next);
setOpen(false);
}}
renderItem={renderRow}
// The rows read previewingId/previewLoadingId from the closure — without
// extraData the FlatList would keep stale rows while the sheet is open.
extraData={[previewingId, previewLoadingId]}
placeholder={placeholder}
emptyTitle={emptyTitle}
emptyDescription={emptyDescription}
title={title}
className={className}
>
{children}
</Command>
</VoiceSelectorContext.Provider>
);
}
type VoiceSelectorTriggerProps = {
/** Overrides the default outline button (web: children on the trigger). */
children?: React.ReactNode;
className?: string;
};
function VoiceSelectorTrigger({ children, className }: VoiceSelectorTriggerProps) {
const { value, open, setOpen, voices } = useVoiceSelector();
// The trigger reads the NAME, never the raw id (findMicRoute/resolveModelLabel
// precedent — review M1: an id like 'voice-9f2a' is not a human label).
const label = value === undefined ? 'Select voice' : (voices.find((v) => v.id === value)?.name ?? value);
return (
children ?? (
<Button
variant="outline"
onPress={() => setOpen(!open)}
accessibilityLabel={`Select voice, current: ${label}`}
accessibilityState={{ expanded: open }}
className={cn('justify-between gap-2', className)}
>
<Icon as={AudioLinesIcon} size={16} className="text-muted-foreground" />
<Text numberOfLines={1} className="flex-1 text-left">
{label}
</Text>
<Icon as={ChevronsUpDownIcon} size={16} className="shrink-0 text-muted-foreground" />
</Button>
)
);
}
type VoiceSelectorNameProps = {
children: string;
className?: string;
};
/** The voice's display name — the web part, one Text. */
function VoiceSelectorName({ children, className }: VoiceSelectorNameProps) {
return <ItemTitle className={className}>{children}</ItemTitle>;
}
type VoiceSelectorDescriptionProps = {
children: string;
className?: string;
};
/** The voice's optional second line — the web part, one Text. */
function VoiceSelectorDescription({ children, className }: VoiceSelectorDescriptionProps) {
return <ItemDescription className={className}>{children}</ItemDescription>;
}
type VoiceSelectorAttributesProps = {
voice: VoiceSelectorVoice;
className?: string;
};
/**
* The gender/accent/age line. Renders nothing when the voice carries no attribute
* fields at all — the web composes the Attributes part per item, so a voice with no
* attributes shows no line, not an empty one. Unknown accents leave a GAP (upstream's
* empty span), never a placeholder glyph.
*/
function VoiceSelectorAttributes({ voice, className }: VoiceSelectorAttributesProps) {
const flag = voiceAccentFlag(voice.accent);
const age = voice.age?.trim();
const hasAny = Boolean(voice.gender || flag || age);
if (!hasAny) return null;
const trailing = [flag, age].filter((part): part is string => Boolean(part)).join(' • ');
return (
<View className={cn('flex-row items-center gap-1', className)}>
<VoiceSelectorGender value={voice.gender} />
{trailing ? (
<Text numberOfLines={1} className="text-xs text-muted-foreground tabular-nums">
{trailing}
</Text>
) : null}
</View>
);
}
type VoiceSelectorGenderProps = {
/** One of VOICE_GENDERS — anything else renders the default mark. */
value?: string;
className?: string;
};
/**
* The gender mark — the web's full set (male/female/transgender/androgyne/non-binary/
* intersex) at size-4/16px, default CircleSmallIcon for omitted or unknown values
* (voice-selector.tsx:205-229).
*/
function VoiceSelectorGender({ value, className }: VoiceSelectorGenderProps) {
const gender = resolveVoiceGender(value);
return (
<View accessible accessibilityLabel={`Voice gender: ${gender}`}>
<Icon
as={genderIcons[gender]}
size={16}
className={cn('shrink-0 text-muted-foreground', className)}
/>
</View>
);
}
type VoiceSelectorAccentProps = {
/** One of the 30 lowercase accent keys — CASE-SENSITIVE; unknown renders NOTHING. */
value?: string;
className?: string;
};
/**
* The accent flag, upstream byte-behavior: exactly-lowercase keys render their flag
* emoji; anything else renders nothing (the web's empty span — no placeholder).
*/
function VoiceSelectorAccent({ value, className }: VoiceSelectorAccentProps) {
const flag = voiceAccentFlag(value);
if (!flag) return null;
return (
<Text className={cn('text-xs text-muted-foreground', className)} accessibilityLabel={value}>
{flag}
</Text>
);
}
type VoiceSelectorAgeProps = {
children?: string;
className?: string;
};
/** The free-text age band ("40-50") — the web part, `tabular-nums` on (:423). */
function VoiceSelectorAge({ children, className }: VoiceSelectorAgeProps) {
if (!children) return null;
return <Text className={cn('text-xs text-muted-foreground tabular-nums', className)}>{children}</Text>;
}
type VoiceSelectorBulletProps = ViewProps;
/**
* The attributes separator — the web's `•` span (aria-hidden, text-border;
* voice-selector.tsx:461-472). RNR Text has no cascade, so the web's inherited
* text-xs is stated explicitly.
*/
function VoiceSelectorBullet({ className, ...props }: VoiceSelectorBulletProps) {
return (
<Text aria-hidden className={cn('text-xs text-border', className)} {...props}>
•
</Text>
);
}
type VoiceSelectorPreviewProps = {
playing?: boolean;
loading?: boolean;
/** Play this row's sample. Absent → the control DISABLES, never disappears. */
onPlay?: () => void;
className?: string;
};
/**
* The loading mark, spinning: the web's <Spinner> (voice-selector.tsx:505) as a
* Reanimated rotate — the PulseRing idiom (speech-input.tsx): withRepeat -1,
* ReduceMotion.System, cancelAnimation cleanup. Under the OS reduce-motion setting
* the animation resolves to its end value — the mark sits still instead of spinning.
*/
function SpinningLoaderIcon({ className }: { className?: string }) {
const rotation = useSharedValue(0);
React.useEffect(() => {
rotation.value = withRepeat(
withTiming(360, { duration: 1000, easing: Easing.linear, reduceMotion: ReduceMotion.System }),
-1,
false,
);
return () => cancelAnimation(rotation);
}, [rotation]);
const style = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));
return (
<Animated.View style={style}>
<Icon as={LoaderCircleIcon} size={12} className={className} />
</Animated.View>
);
}
/**
* THE TWO-TARGET LAW'S PLAY HALF. Lives in the row's action slot — a nested pressable
* wins the touch over the row, so pressing this NEVER selects the voice. Three
* states: play, playing (pause mark), loading (disabled) — and disabled-when-unwired
* (no onPlay), because a control that pretends to play is the lie (speech-input).
*
* Converged to the web control (voice-selector.tsx:511-518): variant outline, 12px
* mark. KEPT on the record: the 40pt hit target (the web button is size-6/24px —
* under the touch floor) and disabled-when-unwired. The mark inherits the outline
* button's text class, as the web's bare icons inherit — no explicit color.
*/
function VoiceSelectorPreview({ playing, loading, onPlay, className }: VoiceSelectorPreviewProps) {
const label = loading ? 'Loading preview' : playing ? 'Pause preview' : 'Play preview';
return (
<Button
variant="outline"
size="icon"
disabled={loading || !onPlay}
onPress={onPlay}
accessibilityLabel={label}
accessibilityState={{ disabled: loading || !onPlay, busy: loading }}
// House formula (item/attachments precedent): h-10 + hitSlop 2/side = the 44pt
// platform minimum, no pixel of extra chrome.
hitSlop={{ top: 2, bottom: 2, left: 2, right: 2 }}
className={cn('shrink-0', className)}
>
{loading ? <SpinningLoaderIcon /> : <Icon as={playing ? PauseIcon : PlayIcon} size={12} />}
</Button>
);
}
export {
useVoiceSelector,
VoiceSelector,
VoiceSelectorAccent,
VoiceSelectorAge,
VoiceSelectorAttributes,
VoiceSelectorBullet,
VoiceSelectorDescription,
VoiceSelectorGender,
VoiceSelectorName,
VoiceSelectorPreview,
VoiceSelectorTrigger,
};
export type { VoiceSelectorVoice };
/**
* VoiceSelector — pure logic. Zero react-native imports, so the Vitest tier owns it
* (see the header of sources.logic.ts for why component files themselves cannot load
* under Node).
*
* The web original's voice set comes from the browser TTS API; the PRD verdict
* (port-adapted) says the available voice set comes from the NATIVE speech synthesis
* provider — so the list is caller-supplied here and this file holds what the
* component derives from it: the accent→flag table, the gender fallback, and the
* preview-button state machine.
*/
/** One voice the caller offers (native TTS provider, ElevenLabs roster, …). */
export type VoiceSelectorVoice = {
id: string;
name: string;
/** Section heading — the web's VoiceSelectorGroup heading, caller-defined. */
group?: string;
description?: string;
/** 'male' | 'female' | 'transgender' | 'non-binary' — anything else falls back, never throws. */
gender?: string;
/** One of the VOICE_ACCENTS keys, CASE-SENSITIVE (upstream byte-behavior). */
accent?: string;
/** Free text, e.g. "40-50". */
age?: string;
/** Extra filter terms beyond name, accent, age and description. */
keywords?: string;
};
/**
* The web original's accent table, verbatim: 30 lowercase keys, each to a flag
* emoji. THE LOOKUP IS CASE-SENSITIVE — upstream renders nothing for "AMERICAN"
* (the KB's documented trap), and the port preserves that byte-behavior rather than
* silently normalizing data the caller can normalize themselves. Android flag-glyph
* coverage varies (KB port advice); the accent is ALSO carried into the filter
* keywords, so the text channel is never lost.
*/
export const VOICE_ACCENTS = {
american: '🇺🇸',
british: '🇬🇧',
australian: '🇦🇺',
canadian: '🇨🇦',
irish: '🇮🇪',
scottish: '🏴',
indian: '🇮🇳',
'south-african': '🇿🇦',
'new-zealand': '🇳🇿',
spanish: '🇪🇸',
french: '🇫🇷',
german: '🇩🇪',
italian: '🇮🇹',
portuguese: '🇵🇹',
brazilian: '🇧🇷',
mexican: '🇲🇽',
argentinian: '🇦🇷',
japanese: '🇯🇵',
chinese: '🇨🇳',
korean: '🇰🇷',
russian: '🇷🇺',
arabic: '🇸🇦',
dutch: '🇳🇱',
swedish: '🇸🇪',
norwegian: '🇳🇴',
danish: '🇩🇰',
finnish: '🇫🇮',
polish: '🇵🇱',
turkish: '🇹🇷',
greek: '🇬🇷',
} as const;
export type VoiceAccent = keyof typeof VOICE_ACCENTS;
/**
* The flag for `accent`, or undefined when it is unknown or not exactly lowercase —
* "AMERICAN" renders NOTHING, by upstream behavior. Callers render nothing for
* undefined, which is the web's empty span.
*/
export function voiceAccentFlag(accent: string | undefined): string | undefined {
if (!accent) return undefined;
return (VOICE_ACCENTS as Record<string, string | undefined>)[accent];
}
export const VOICE_GENDERS = [
'male',
'female',
'transgender',
'androgyne',
'non-binary',
'intersex',
] as const;
export type VoiceGender = (typeof VOICE_GENDERS)[number] | 'unknown';
/**
* The gender key for the icon lookup. UNKNOWN VALUES FALL BACK, NEVER THROW (the
* KB's documented gender behavior) — and unlike the accent table, the match is
* case-INSENSITIVE: the web documents graceful fallback for gender with no
* case-sensitivity language, so "Male" resolves instead of silently icon-less.
* undefined and unrecognized inputs return 'unknown', which renders the default mark.
*/
export function resolveVoiceGender(gender: string | undefined): VoiceGender {
const key = gender?.trim().toLowerCase();
return (VOICE_GENDERS as readonly string[]).includes(key ?? '')
? (key as Exclude<VoiceGender, 'unknown'>)
: 'unknown';
}
export type VoicePreviewState = 'idle' | 'playing' | 'loading';
/**
* The preview button's state for one voice: loading wins over playing (a restart
* requested mid-playback shows loading), playing needs previewingId === voiceId, and
* everything else is idle. Loading is disabled; playing shows the pause mark.
*/
export function voicePreviewState(
voiceId: string,
previewingId?: string,
previewLoadingId?: string,
): VoicePreviewState {
if (previewLoadingId && previewLoadingId === voiceId) return 'loading';
if (previewingId && previewingId === voiceId) return 'playing';
return 'idle';
}
/**
* Command — pure logic. Zero react-native imports, so the Vitest tier owns it (see the
* header of sources.logic.ts for why component files themselves cannot load under
* Node).
*
* Extracted when model-selector needed provider grouping and voice-selector needed
* custom rows: the palette's filter and its flat-item → render-rows transform are the
* two behaviors those selectors build on, and both are data-in/data-out. The component
* file consumes them; tests pin them.
*/
/** One selectable entry in the palette. `group` names the section heading it sits under. */
export type CommandItem = {
value: string;
label: string;
description?: string;
/** Extra match terms beyond label and description — model-selector puts the provider here. */
keywords?: string;
/** Section heading. Ungrouped items render without a header, in list order. */
group?: string;
};
/** A row of the palette's list: either a section heading or a selectable item. */
export type CommandRow =
| { kind: 'header'; key: string; label: string }
| { kind: 'item'; key: string; item: CommandItem; selected: boolean };
/**
* The palette filter — the web original's cmdk match (case-insensitive substring
* across label, description and keywords). A blank query matches everything; a
* whitespace query is blank. Order is preserved — the caller's list order IS the
* display order.
*/
export function filterCommandItems<T extends CommandItem>(
items: readonly T[],
query: string,
): T[] {
const q = query.trim().toLowerCase();
if (!q) return [...items];
return items.filter((i) =>
`${i.label} ${i.description ?? ''} ${i.keywords ?? ''}`.toLowerCase().includes(q),
);
}
/**
* Interleave section headings into the flat item list: a group's heading renders the
* first time one of its items appears, so groups keep the caller's first-appearance
* order and interleaved groups each get exactly one header. Runs AFTER filtering, so
* a group whose items all failed the filter renders no header at all.
*
* `selected` is resolved here, not at render time, so the memoized row array changes
* identity when the selection changes — the FlatList's re-render trigger.
*/
export function buildCommandRows(
items: readonly CommandItem[],
value?: string,
): CommandRow[] {
const rows: CommandRow[] = [];
const seen = new Set<string>();
for (const item of items) {
const group = item.group?.trim();
if (group && !seen.has(group)) {
seen.add(group);
rows.push({ kind: 'header', key: `header:${group}`, label: group });
}
rows.push({ kind: 'item', key: item.value, item, selected: item.value === value });
}
return rows;
}
Props
| Name | Type | Default | Description |
|---|---|---|---|
| voices | readonly VoiceSelectorVoice[] | — | The voices YOUR provider offers — native TTS or a hosted roster. |
| value | string | undefined | — | — |
| onValueChange | (value: string) => void | — | — |
| onPreview | ((voice: VoiceSelectorVoice) => void) | undefined | — | Play this voice's sample. Absent → every preview control DISABLES, never fakes. |
| previewingId | string | undefined | — | The voice whose sample is currently playing (pause mark on that row). |
| previewLoadingId | string | undefined | — | The voice whose sample is loading (disabled control on that row). |
| open | boolean | undefined | — | — |
| defaultOpen | boolean | undefined | false | The web contract ships defaultOpen; the sheet opens closed without it. |
| onOpenChange | ((open: boolean) => void) | undefined | — | — |
| placeholder | string | undefined | 'Search voices…' | — |
| emptyTitle | string | undefined | 'No voices found' | — |
| emptyDescription | string | undefined | 'Try a different search.' | — |
| title | string | undefined | 'Select AI Voice' | The sheet's screen-reader title — the web Content's `title` prop. |
| className | string | undefined | — | Passed to the sheet content — height and padding adjustments. |
| children | React.ReactNode | — | — |
Theming
Themed via text-muted-foreground, text-border.
Usage
() => (
<VoiceSelector
voices={[
{ id: 'alloy', name: 'Alloy', group: 'Neutral', description: 'Balanced and clear', gender: 'female', accent: 'american' },
{ id: 'echo', name: 'Echo', group: 'Neutral', description: 'Warm and even', gender: 'male', accent: 'american' },
{ id: 'fable', name: 'Fable', group: 'Expressive', description: 'British storytelling', gender: 'female', accent: 'british' },
]}
value="alloy"
onValueChange={() => {}}
>
<VoiceSelectorTrigger />
</VoiceSelector>
)