Skip to content
VoiceVoice controlsMenu

Voice controls

The call bar: mute with a live input level and a microphone menu, speaker, captions, your own controls and End.

Mute with a live level, microphone and speaker menus, captions and End on a simulated call.

Installation

$ pnpm dlx shadcn@latest add https://chatcn.dev/r/voice-controls.json

Usage

tsx
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.

Blocked microphone with its fix, connecting, muted, captions with an extra control, disabled, large.

Extra controls

Children render before End. Use VoiceControlButton so they share the size, the press feedback and the pressed style.

tsx
<VoiceControls muted={muted} onMutedChange={setMuted} onEnd={endCall}>
  <VoiceControlButton label="Switch to text" onClick={() => setMode("text")}>
    <KeyboardIcon />
  </VoiceControlButton>
</VoiceControls>

Props

PropTypeNotes
muted, onMutedChangebooleanThe mute toggle, with aria-pressed
inputLevelnumber | () => numberLive bars in the mic button. A getter never re-renders
microphones, microphone, onMicrophoneChangeChatcnAudioDevice[], stringThe chevron menu beside the mic. Hidden without devices
speakers, speaker, onSpeakerChangeChatcnAudioDevice[], stringThe speaker menu. Hidden without devices
captions, onCaptionsChangebooleanCaptions toggle. Hidden without the handler
micErrorChatcnMicrophoneErrorCrossed mic and a hint that says how to fix it
onRetryMicrophone() => voidShows Try again in the hint
status"connecting" | "active" | "ended"Connecting reads Cancel; ended hides End
onEnd, endLabel() => void, stringEnd the call. Default label End
size"md" | "lg"36 px or 44 px controls. Always 44 px on touch
disabledbooleanDisables every control
childrenReactNodeExtra controls before End

VoiceControlButton takes label (the accessible name and tooltip), pressed (makes it a toggle), tooltip (override, or false) and button props.

Keyboard

KeyAction
TabMove through mute, the device menus, captions, your controls and End
Enter, SpaceToggle mute or captions, open a menu, end the call
Arrow keysMove through devices in an open menu
EscClose 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.