52 / Agent UI
Mic Selector
Chooses which microphone to use, then settles the question by lighting a meter from the one you chose.
Names stay hidden until a page has been granted the microphone once, so the list reads Microphone 1 and 2 until you run the test.
"use client"
import * as React from "react"
import { MicSelector } from "mischief-ui/mic-selector"
export function MicSelectorDemo() {
const [chosen, setChosen] = React.useState("")
return (
<div className="w-full max-w-md space-y-3">
<MicSelector value={chosen} onValueChange={setChosen} />
<p className="text-muted-foreground text-xs">
Names stay hidden until a page has been granted the microphone once, so
the list reads Microphone 1 and 2 until you run the test.
</p>
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/mic-selectorimport { MicSelector } from "mischief-ui/mic-selector"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react"import { Mic, Square } from "lucide-react" import { cn } from "@/lib/utils" export type MicSelectorStatus = "unsupported" | "idle" | "requesting" | "testing" | "denied" | "error" export type MicSelectorProps = Omit< React.HTMLAttributes<HTMLDivElement>, "onChange" | "onError" | "children"> & {Usage
export function Setup() {
const [device, setDevice] = React.useState("")
return <MicSelector value={device} onValueChange={setDevice} />
}Why the names are missing at first
A page that has never been granted the microphone can count the devices but not read their labels. Browsers withhold them because a list of attached hardware identifies a machine well enough to track it.
So an untested list reads Microphone 1 and Microphone 2, and fills in with real names the moment the test grants permission. Showing numbered placeholders is honest about that. Asking for the microphone on mount, purely to read the labels, is not.
Testing opens the device
The meter needs the microphone open, which is a permission prompt and a recording indicator, so it happens when the test is pressed and not before. Pressing it again, changing device, or leaving the page closes the stream and the audio context.
The meter reads loudness rather than the loudest sample, so a chair creak does not light the whole row and leave it there. It is drawn as segments that turn on and off, which means it stays readable under reduced motion instead of being replaced by something else.
API
valuestringThe chosen device id, when you are holding the value.defaultValuestringThe device to start on, uncontrolled.onValueChange(deviceId: string) => voidA different microphone was chosen.onStatusChange(status: MicSelectorStatus) => voidEvery state change, if you are mirroring it elsewhere.segmentsnumberSegments in the meter. Defaults to 12.labelstringNames the list and the test button.disabledbooleanTurns the control off without changing its state....rootPropsHTMLAttributes<HTMLDivElement>Native root attributes.Accessibility
The list is a native select, so it works with a keyboard and reads out as a list of options without any ARIA. The test button is a real toggle with aria-pressed and a name that says which way it will go. Every state, including a refused permission and a browser that cannot enumerate at all, is announced through a polite live region and written on the element as data-status. The meter is decoration: it is hidden from assistive technology, and the same information is in the message beside it.