Voice controls
The call bar: mute with a live input level and a microphone menu, speaker, captions, your own controls and End.
On this page
Installation
Usage
import { VoiceControls } from "@/components/ui/chatcn/voice-controls"
import { useChatcnAudioLevel } from "@/hooks/use-chatcn-audio-level"
import { useChatcnMicrophone, useChatcnVoiceDevices } from "@/hooks/use-chatcn-voice-devices"
const devices = useChatcnVoiceDevices()
const mic = useChatcnMicrophone({ deviceId })
const level = useChatcnAudioLevel(mic.stream, { updateRate: 0 })
<VoiceControls
muted={muted}
onMutedChange={(next) => {
setMuted(next)
mic.setMuted(next)
}}
microphones={devices.microphones}
microphone={deviceId}
onMicrophoneChange={setDeviceId}
speakers={devices.canPickSpeaker ? devices.speakers : undefined}
inputLevel={level.getLevel}
micError={mic.error}
onRetryMicrophone={mic.start}
captions={captions}
onCaptionsChange={setCaptions}
onEnd={endCall}
/>Every control is optional and appears only when you wire it: no microphones, no device menu; no onCaptionsChange, no captions toggle. Device labels stay empty until the page has microphone permission, so start the microphone before showing the menus.
Examples
States
A blocked microphone shows a crossed mic and opens a hint above it that says how to fix it, with Try again. While connecting, End reads Cancel. Muted inverts both halves of the split button and the level drops to flat strokes, so the button keeps its width.
Extra controls
Children render before End. Use VoiceControlButton so they share the size, the press feedback and the pressed style.
<VoiceControls muted={muted} onMutedChange={setMuted} onEnd={endCall}>
<VoiceControlButton label="Switch to text" onClick={() => setMode("text")}>
<KeyboardIcon />
</VoiceControlButton>
</VoiceControls>Props
| Prop | Type | Notes |
|---|---|---|
muted, onMutedChange | boolean | The mute toggle, with aria-pressed |
inputLevel | number | () => number | Live bars in the mic button. A getter never re-renders |
microphones, microphone, onMicrophoneChange | ChatcnAudioDevice[], string | The chevron menu beside the mic. Hidden without devices |
speakers, speaker, onSpeakerChange | ChatcnAudioDevice[], string | The speaker menu. Hidden without devices |
captions, onCaptionsChange | boolean | Captions toggle. Hidden without the handler |
micError | ChatcnMicrophoneError | Crossed mic and a hint that says how to fix it |
onRetryMicrophone | () => void | Shows Try again in the hint |
status | "connecting" | "active" | "ended" | Connecting reads Cancel; ended hides End |
onEnd, endLabel | () => void, string | End the call. Default label End |
size | "md" | "lg" | 36 px or 44 px controls. Always 44 px on touch |
disabled | boolean | Disables every control |
children | ReactNode | Extra controls before End |
VoiceControlButton takes label (the accessible name and tooltip), pressed (makes it a toggle), tooltip (override, or false) and button props.
Keyboard
| Key | Action |
|---|---|
Tab | Move through mute, the device menus, captions, your controls and End |
Enter, Space | Toggle mute or captions, open a menu, end the call |
| Arrow keys | Move through devices in an open menu |
Esc | Close a menu or the microphone hint |
The bar is a labelled group with plain Tab order rather than a single-stop toolbar: it holds five or six controls that people reach for by name, often mid-sentence, and the menus already own the arrow keys.
Accessibility
- The group is named Call controls. Mute is one toggle named Mute microphone with
aria-pressed, so its name never flips while its state does. - Muted is never color alone: the icon changes to a crossed mic and the button inverts. A blocked mic adds a red crossed mic, the words Microphone blocked and the fix.
- The microphone hint is an
alert, opens without stealing focus and closes with Esc. - Device menus are radio groups with a check on the current device; the system default is labelled Default.
- Controls are 36 px (44 px in
lg) and grow to 44 px on coarse pointers.
Motion
- Level bars follow the input with a fast rise and a slower fall, written straight to the DOM each frame.
- Mute swaps its glyph with a 150 ms blur crossfade; buttons press to 0.97 over 100 ms.
- Menus and the hint open from their trigger in 180 ms; opened with the keyboard they appear at once.
- Under reduced motion the bars hold still at half height and the crossfade is removed.