shadcn.io is an independent project built on shadcn/ui
A button that shows the selected microphone and opens a searchable list of every audio input the browser sees.
Use it before recording, where a person may have a laptop microphone, a headset and a webcam at once and the browser's default isn't the one they speak into. The list follows the hardware: plug a microphone in and it appears, unplug it and it goes. In React, it's a Popover holding a Command, and it hands you the chosen deviceId: recording from it, with getUserMedia, is yours.
Installation
Anatomy

MicSelector
├── MicSelectorTrigger
│ └── MicSelectorValue
└── MicSelectorContent
├── MicSelectorInput
└── MicSelectorList
├── MicSelectorEmpty
└── MicSelectorItem (one per microphone)
└── MicSelectorLabelMicSelectorList renders the items itself, one per microphone with its label; its children are what shows when the search matches none.
Usage
import { MicIcon } from "lucide-react";
import { useState } from "react";
import {
MicSelector, MicSelectorContent, MicSelectorEmpty, MicSelectorInput,
MicSelectorList, MicSelectorTrigger, MicSelectorValue,
} from "@/components/ui/mic-selector";export function MicrophonePicker() {
const [deviceId, setDeviceId] = useState<string>();
return (
<MicSelector onValueChange={setDeviceId} value={deviceId}>
<MicSelectorTrigger className="w-full">
<MicIcon aria-hidden="true" />
<MicSelectorValue />
</MicSelectorTrigger>
<MicSelectorContent>
<MicSelectorInput />
<MicSelectorList>
<MicSelectorEmpty />
</MicSelectorList>
</MicSelectorContent>
</MicSelector>
);
}Microphone access
Listing the microphones asks for nothing, but until the page may use the microphone the browser keeps their names to itself, and often the microphones too. So, until then, the list starts with an Allow microphone access row. Picking it shows the browser's prompt; once the person allows it, the list fills with every microphone by name. Opening the list never prompts on its own, and neither does loading the page.
Record from the chosen one by its deviceId:
const stream = await navigator.mediaDevices.getUserMedia({
audio: { deviceId: { exact: deviceId } },
});A name that ends with a USB vendor and product id, such as Yeti Stereo Microphone (046d:0ab7), shows the id muted after the name.
Demos
Devices
devices lists microphones of your own instead of the browser's: here five, the Yeti selected, as a page shows them once access is allowed. Pass it to show a list you keep yourself, or in tests and previews where there's no microphone. Each one needs a deviceId and a label, which a browser's MediaDeviceInfo has.
Audio Devices
useAudioDevices reads the browser's microphones from inside a MicSelector, for a part of your own: here a line under the trigger with how many there are, and a button that asks for access. Its loadDevices is what the Allow microphone access row calls.
Labels
Every word it shows can change: placeholder on MicSelectorValue, placeholder on MicSelectorInput, permissionLabel on MicSelectorList, and the children of MicSelectorEmpty. Here they're in French.
useAudioDevices
Called inside a MicSelector, it returns the browser's microphones, whether or not devices replaces them in the list.
| Field | What it is |
|---|---|
devices | The audio inputs as MediaDeviceInfo, kept current on devicechange |
hasPermission | Whether the page may use the microphone, so the devices have names |
loadDevices | Asks for the microphone, then lists the devices again with their names |
loading | Whether they're being listed |
error | Why they couldn't be listed or access was refused, else null |
Migrating from AI SDK Elements
If you added Mic Selector from AI SDK Elements@/components/ai-elements/mic-selector to @/components/ui/mic-selector. The parts keep their names, and value, defaultValue, onValueChange, open and onOpenChange work the same. What differs:
MicSelectorListtakes no function: drop{(devices) => …}and keep<MicSelectorEmpty />as its child. It renders aMicSelectorItemandMicSelectorLabelper microphone itself.- Opening the list doesn't ask for the microphone: the Allow microphone access row does.
useAudioDevicesreturns the same fields, but reads theMicSelectoraround it instead of standing alone.MicSelectorContent's props go to the popover, andpopoverOptionsis gone. It matches the trigger's width without aResizeObserver.
FAQ
Why does Mic Selector show Allow microphone access instead of my microphones?
Browsers hide microphones' names, and usually the microphones themselves, until the page may use one. Pick the row and allow access in the browser's prompt, and the list fills in. Once access is allowed, for this visit or for good, the row is gone.
How do I record from the microphone picked in Mic Selector?
onValueChange gives you its deviceId. Pass it to getUserMedia as audio: { deviceId: { exact: deviceId } }, as the Microphone access section shows. Keep the id: it usually stays the same for this site in that browser, so the next visit can select it again.
Does Mic Selector update when a microphone is plugged in?
Yes. It lists the devices again on the browser's devicechange event, so a headset plugged in or out appears or goes while the page is open. A selected microphone that's unplugged leaves the trigger showing the placeholder.
How do I show Mic Selector without real microphones?
Pass devices, an array of deviceId and label pairs, as the Devices demo does. The list shows those and skips the access row. It's how to keep a list of your own, or to render it in tests and screenshots where there's no hardware.
Can Mic Selector be used with the keyboard?
Yes. Enter or Space on the trigger opens the list with the search focused; typing filters by name, the arrow keys move, Enter picks, and Escape closes. Focus goes back to the trigger, which reads out the microphone it shows.
Is this the same as AI SDK Elements' Mic Selector?
It has the same parts and props, so most code only changes its import. The list renders its own items instead of taking a function, and it asks for the microphone from a row rather than on open. The Migrating section lists every difference.
API Reference
MicSelector
open?boolean
Whether it's open (controlled).
defaultOpen?boolean
Whether it starts open, when uncontrolled.
- Default
false
onOpenChange?(open: boolean) → void
Called when it opens or closes.
- Parameters
open– boolean- Returns
void
value?string
The selected microphone's deviceId (controlled).
defaultValue?string
The microphone selected at first, when uncontrolled: its deviceId.
onValueChange?(value: string) → void
Called with the deviceId of the microphone picked.
- Parameters
value– string- Returns
void
actionsRef?React.RefObject<PopoverRoot.Actions | null>
A ref to imperative actions.
- unmount: Ends the closing phase of the popover after an externally controlled closing animation finishes.
Call preventUnmountOnClose() in onOpenChange first, otherwise the popover completes closing on its own.
Whether it leaves the DOM is decided by keepMounted on the portal.
- close: Closes the popover imperatively when called.
- Type
React.RefObject<PopoverRoot.Actions | null>
children?React.ReactNode | PayloadChildRenderFunction<Payload>
The content of the popover.
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 popover is associated with.
This is useful in conjunction with the defaultOpen prop to create an initially open popover.
- Type
string | null
devices?MicSelectorDevice[]
The microphones to list instead of the browser's: your own list, or a demo's. Without it, it lists the browser's.
handle?PopoverHandle<Payload>
A handle to associate the popover with a trigger. If specified, allows external triggers to control the popover's open state.
- Type
PopoverHandle<Payload>
modal?boolean | "trap-focus"
Determines if the popover enters a modal state when open.
- true: user interaction is limited to the popover: 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 popover, but document page scroll is not locked and pointer interactions outside of it remain enabled.
On touch devices, a true modal blocks outside taps but leaves the page scrollable unless the popup spans nearly the full viewport width, matching native iOS behavior.
When modal is true, focus trapping is enabled only if <Popover.Close> is rendered
inside <Popover.Popup>. It can be visually hidden with your own CSS if needed, such as
Tailwind's sr-only utility.
When modal is trap-focus, render <Popover.Close> inside <Popover.Popup> so touch
screen readers can escape the popup.
- Type
boolean | "trap-focus"- Default
false
onOpenChangeComplete?(open: boolean) → void
Event handler called after any animations complete when the popover is opened or closed.
- Parameters
open– boolean- Returns
void
triggerId?string | null
ID of the trigger that the popover is associated with.
This is useful in conjunction with the open prop to create a controlled popover.
There's no need to specify this prop when the popover is uncontrolled (that is, when the open prop is not set).
- Type
string | null
MicSelectorTrigger
data-slot="mic-selector-trigger"
closeDelay?number
How long to wait before closing the popover that was opened on hover.
Specified in milliseconds.
Requires the openOnHover prop.
- Default
0
delay?number
How long to wait before the popover may be opened on hover. Specified in milliseconds.
Requires the openOnHover prop.
- Default
300
handle?PopoverHandle<Payload>
A handle to associate the trigger with a popover.
- Type
PopoverHandle<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 popover in controlled mode (with the PopoverRoot 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 (e.g. <div>).
- Default
true
openOnHover?boolean
Whether the popover should also open when the trigger is hovered.
- Default
false
payload?Payload
A payload to pass to the popover 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)
className?string
Merged with the part's classes.
MicSelectorContent
data-slot="mic-selector-content"
finalFocus?boolean | React.RefObject<HTMLElement | null> | ((closeType: InteractionType) → void | boolean | HTMLElement | null)
Determines the element to focus when the popover is closed.
- false: Do not move focus.
- true: Move focus based on the default behavior (trigger or previously focused element).
- RefObject: Move focus to the ref element.
- function: Called with the interaction type (mouse, touch, pen, or keyboard).
Return an element to focus, true to use the default behavior, null to fall back to the default behavior, or false/undefined to do nothing.
- Type
boolean | React.RefObject<HTMLElement | null> | ((closeType: InteractionType) → void | boolean | HTMLElement | null)
initialFocus?boolean | React.RefObject<HTMLElement | null> | ((openType: InteractionType) → void | boolean | HTMLElement | null)
Determines the element to focus when the popover is opened.
By default, focus moves to the first tabbable element inside the popup, except when the popover
is opened by touch — then the popup itself is focused to avoid opening the virtual keyboard.
- false: Do not move focus.
- true: Move focus based on the default behavior (first tabbable element or popup).
- RefObject: Move focus to the ref element.
- function: Called with the interaction type (mouse, touch, pen, or keyboard).
Return an element to focus, true to use the default behavior, null to fall back to the default behavior, or false/undefined to do nothing.
- Type
boolean | React.RefObject<HTMLElement | null> | ((openType: InteractionType) → void | boolean | HTMLElement | null)
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.
--anchor-widthstring
The anchor's width.
MicSelectorInput
data-slot="mic-selector-input"
asChild?boolean
Merge props onto the child element instead of rendering a host.
MicSelectorList
data-slot="mic-selector-list"
permissionLabel?string
The row that asks for the microphone, shown until the page may use it.
- Default
"Allow microphone access"
asChild?boolean
Merge props onto the child element instead of rendering a host.
className?string
Merged with the part's classes.
MicSelectorEmpty
data-slot="mic-selector-empty"
asChild?boolean
Merge props onto the child element instead of rendering a host.
MicSelectorItem
data-slot="mic-selector-item"
valuestring
The microphone's deviceId.
asChild?boolean
Merge props onto the child element instead of rendering a host.
data-checked?stringMicSelectorLabel
<span> data-slot="mic-selector-label"
deviceobject
The microphone to name.
- Type
{ deviceId: string; label: string }
className?string
Merged with the part's classes.
MicSelectorValue
<span> data-slot="mic-selector-value"
placeholder?string
What it says before a microphone is selected.
- Default
"Select microphone..."
className?string
Merged with the part's classes.











