Voice Call Controls UI
The chrome around a live voice call — an unmistakable mute toggle, a running call timer, and an end button that's always one tap away.
npx shadcn@latest add https://scrimui.dev/r/voice-call-controls.jsonAgent promptClaude Code · Cursor · any agent
Add the Voice Call Controls UI component from the Scrim UI registry to this project and use it in the configuration described below.
Voice Call Controls UI — The chrome around a live voice call — an unmistakable mute toggle, a running call timer, and an end button that's always one tap away.
## 1. Install
```bash
npx shadcn@latest add https://scrimui.dev/r/voice-call-controls.json
```
This writes a single file to `components/ui/voice-call-controls.tsx`. It is plain React + Tailwind with no runtime dependencies — no Radix, no CVA, nothing to add to package.json. If this project does not use the shadcn CLI, copy the source from https://scrimui.dev/components/voice-call-controls to the same path by hand; nothing in the file depends on shadcn.
## 2. Use it
```tsx
import { VoiceCallControls } from "@/components/ui/voice-call-controls";
<VoiceCallControls
elapsedSeconds={74}
onToggleMute={() => {}}
onEnd={() => {}}
/>
```
## 3. The configuration that matters
This is the component's "Live call" state — Mic open, clock running — the timer is the only persistent evidence the session is alive.
Every prop is at its default value. Keep the call site minimal — do not write out default values.
## 4. Wire the handlers
`onToggleMute`, `onEnd` are stubs in the snippet above (`() => {}`). Connect them to this project's own state and data layer instead of shipping the empty functions.
Reference: https://scrimui.dev/components/voice-call-controlsFollows the props below — change a control and the prompt changes with it, so an agent reproduces that configuration instead of the defaults.
Presets
Props
Also takes onToggleMute, onEnd.
Component source
Single-file React + Tailwind component. No dependencies — drop it into any project with Tailwind configured.
"use client";
import * as React from "react";
/**
* The chrome around a live voice call.
*
* Voice input is a button; a voice *call* is a session, and a session needs
* its own controls. Three things, no more: am I muted, how long has this run,
* and how do I get out.
*
* **Mute is the trust control.** In a call that is always listening, the mic
* toggle is the single thing a user checks before saying something private.
* It is a toggle with an unmistakable state — a slashed mic and a label,
* never an icon swap alone — and it stays in the same spot for the whole
* call, because hunting for mute mid-sentence is the failure mode.
*
* **The clock proves the line is open.** A voice call has no scrollback; the
* elapsed timer is the only persistent evidence the session is alive. When it
* stops, the call has dropped whether the UI admits it or not.
*
* **End is red and requires no courage.** One tap, always visible, never
* hidden behind a menu. A user who cannot find the exit will find it by
* killing the tab, and takes the trust damage with them.
*
* What is deliberately not here: transcript, waveform, volume. Those belong
* to the conversation surface above the bar; this is only the chrome.
*/
export type VoiceCallControlsProps = {
/** Whether the microphone is currently muted. */
muted?: boolean;
/** Fired when the mute toggle is pressed. */
onToggleMute?: () => void;
/** Seconds into the call — rendered as m:ss. */
elapsedSeconds?: number;
/** Fired when the end-call button is pressed. */
onEnd?: () => void;
className?: string;
};
/* ------------------------------------------------------------------ */
/* Icons */
/* ------------------------------------------------------------------ */
function MicIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="18" height="18">
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<path d="M12 19v3" />
</svg>
);
}
function MicOffIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="18" height="18">
<line x1="2" x2="22" y1="2" y2="22" />
<path d="M18.89 13.23A7.12 7.12 0 0 0 19 12v-2" />
<path d="M5 10v2a7 7 0 0 0 12 5" />
<path d="M15 9.34V5a3 3 0 0 0-5.68-1.33" />
<path d="M9 9v3a3 3 0 0 0 5.12 2.12" />
<path d="M12 19v3" />
</svg>
);
}
function PhoneOffIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="18" height="18">
<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91" />
<line x1="22" x2="2" y1="2" y2="22" />
</svg>
);
}
function formatElapsed(total: number): string {
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
/* ------------------------------------------------------------------ */
/* VoiceCallControls */
/* ------------------------------------------------------------------ */
export function VoiceCallControls({
muted = false,
onToggleMute,
elapsedSeconds = 0,
onEnd,
className = "",
}: VoiceCallControlsProps) {
return (
<div
className={`flex items-center justify-between rounded-full border border-zinc-200 bg-white px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900 ${className}`}
role="toolbar"
aria-label="Voice call controls"
>
<button
type="button"
onClick={onToggleMute}
aria-pressed={muted}
className={`flex h-10 items-center gap-2 rounded-full px-3 text-sm font-medium transition-colors ${
muted
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-800"
}`}
>
{muted ? <MicOffIcon /> : <MicIcon />}
{muted ? "Muted" : "Mute"}
</button>
<span
className="text-sm tabular-nums text-zinc-500 dark:text-zinc-400"
aria-label={`Call duration ${formatElapsed(elapsedSeconds)}`}
>
{formatElapsed(elapsedSeconds)}
</span>
<button
type="button"
onClick={onEnd}
className="flex h-10 items-center gap-2 rounded-full bg-red-600 px-4 text-sm font-medium text-white transition-colors hover:bg-red-500"
>
<PhoneOffIcon />
End
</button>
</div>
);
}
When to use it
- Make mute unmistakable — slashed mic, filled toggle, and the word 'Muted'. An icon swap alone is missed at exactly the wrong moment.
- Keep the elapsed timer running and visible; in a call with no scrollback it is the only proof the line is still open.
- End stays one tap, always visible, never in a menu — a user who can't find the exit kills the tab instead.
- Keep transcript and waveform on the conversation surface above; this bar is only the session chrome.
What breaks in production
- Hiding mute behind a long-press or a settings sheet during a live call.
- Letting the timer freeze on a dropped connection — the UI then lies about the call being alive.
- Ending the call with the same styling as every other button, so the destructive action has no gravity.
Related Components
A microphone button that expands into a recording panel — live waveform, a running transcript, and cancel or send.
An animated audio waveform — distinct bar motion for listening, recording, and the assistant speaking back.
A voice conversation as it happens — who is speaking, the live transcript, and replay for any individual turn.
The message input at the heart of an AI chat app — file attachments, model picker, tool toggles, voice, and a send button that turns into stop.