Pro

Voice session

Pro block

A live voice call with an AI agent: a calm visual of who's speaking, a state label that morphs in place, a transcript where the agent's words appear as they're said and lookups show inline, mute, device and captions controls, and a summary of what was decided when you hang up. Covers a blocked or missing mic, slow connections, network drops and agent errors.

Call with Copper

Menu for Saturday’s supper · 14 guests

Call time:

Allow microphone

Your browser is asking to use the microphone. Choose Allow.

The transcript starts when the call connects.

    Installation

    This is a Pro block. UI Polish Pro costs $149 one time and includes every Pro component, block and template, now and later. Add your license key to your MCP connection or components.json, then install it either way below.

    With UI Polish connected, ask your agent:

    Add the UI Polish voice session to this project.

    Or with the shadcn CLI:

    npx shadcn@latest add @uipolish/voice-session
    Package@base-ui/react
    Brings inAvatar, Badge, Button, Icons, Message thread, Segmented control, Select, Text, Text morph, Tooltip

    The screen for a live voice call with an agent. In the middle, a disc with five bars shows who has the floor: the text color for the agent, the accent for you. A voice lifts the bars smoothly; silence leaves five still dots. Under it, the agent's state in words (Connecting…, Listening, Thinking, Speaking, Muted, Reconnecting…) morphs in place without moving anything, with one line of what that means and, when there's one, a single action: Interrupt while it talks, Try again when it fails, Cancel when connecting drags. Beside it, the transcript: your words in a quiet tone while the recognizer may still change them, settling when they're final; the agent's words appearing as the voice reaches them; and its lookups inline ("Checking the guest list", then "Checked the guest list · 14 going"). At the foot: the microphone, Mute (M), Captions (C) and End call. Hanging up shows a summary: how long, what was decided, what happens next, and the transcript folded underneath.

    TSX
    <VoiceSession  agentName="Copper"  topic="Menu for Saturday’s supper · 14 guests"  status={call.status} // "requesting" | "connecting" | "live" | "reconnecting" | "error" | "denied" | "no-mic" | "ended"  activity={call.activity} // "listening" | "thinking" | "speaking"  levels={() => ({ user: micMeter.level(), agent: voiceMeter.level() })}  muted={muted}  onMutedChange={(m) => {    setMuted(m);    micTrack.enabled = !m;  }}  captions={captions}  onCaptionsChange={setCaptions}  devices={mics} // from navigator.mediaDevices.enumerateDevices()  deviceId={micId}  onDeviceChange={(id) => call.switchMic(id)}  transcript={call.transcript}  startedAt={call.startedAt}  endedAt={call.endedAt}  error={call.error?.message}  onInterrupt={() => call.cancelResponse()}  onEnd={() => call.hangUp()}  onRetry={() => (call.status === "error" ? call.retry() : call.requestMic())}  summary={call.summary}  summaryError={call.summaryError}  onRetrySummary={() => call.summarize()}  onCallAgain={() => call.start()}/>

    It's controlled and fetches nothing: your realtime client (WebRTC, a WebSocket to a speech model, a telephony SDK) reports where the call is and what's been said, and the screen draws it. It fills its container's height; give it one.

    When to use it

    • A spoken conversation with an agent, in the browser: planning, support, intake, tutoring, a phone-style assistant.
    • When the person needs to see what was heard, what the agent looked up, and to cut in, mute or switch microphones mid-call.

    When not to

    • One spoken message, sent and then answered in text: that's voice-input in a composer.
    • A text chat that can also speak: use the Chat app template and add a voice button.
    • Calls between people. The turn-taking, interruption and lookups here are about an agent.

    States

    statusStageTranscriptControls
    requestingStill dots, "Allow microphone", "Your browser is asking to use the microphone. Choose Allow.""The transcript starts when the call connects."Mic, Mute, Captions, End call
    connectingOne slow wave crossing the dots, "Connecting…". After slowAfter (6s): "This is taking longer than usual…" and Cancel, which calls onEndAs aboveAs above
    live, listeningDots at rest; your voice lifts them in the accent and "You" shows above "Listening"Your words, quiet until final
    live, thinkingThe wave in the text color, "Thinking"; the line under it names the lookup that's runningThe lookup, with a turning arc in its icon slot
    live, speakingBars in the text color following the agent's voice, "Speaking", "Start talking to interrupt.", InterruptWords appear as spoken reaches them
    live, muted"Muted", "Copper can’t hear you. Unmute to talk." Your voice no longer moves the barsMute turns red and reads Unmute
    Barge-inThe moment your voice rises over the agent's, the disc turns to you; when you mark its line interrupted, the line ends with an "Interrupted" tag and the stage says "Copper stopped so you can talk." for a few seconds
    reconnectingThe wave in amber, "Reconnecting…", "Your connection dropped. The call picks up where it left off." The dot by the timer breathes amber; the timer keeps countingHoldsAll stay; Mute still works
    Back to live"Reconnected. Nothing was lost." in green for a few seconds
    errorStill red dots, "Not responding", error, Try again (onRetry)HoldsAll stay
    deniedReplaces stage and transcript: "Microphone blocked", steps for Chrome, Safari, Firefox or Edge (the one in use is picked; the others a tap away), where the OS setting lives, Try again and Not nowHidden
    no-mic"No microphone found", what to check, Try again and Not nowHidden
    endedThe summary: "Call ended", the duration, Decided, Follow-ups with whose they are, Call again and Copy summary, and the transcript folded underneath. Without summary: "Writing up the call…" shimmering (after 300ms) in space held for it. With summaryError: the error and Try againHidden; focus moves to "Call ended"

    Nothing on the stage moves between states: the disc, the speaker's name, the label (every label held in one cell), two lines for the hint and the action's 40px slot are always there. The timer sits over "88:88" in mono, so the seconds never nudge the header.

    Props

    PropTypeDefault
    agentNamestring"Assistant"Names the call, the speaker and the copy.
    agentImagestringIts picture; else its initial.
    topicstringUnder the name, and in the summary. Wraps.
    statusVoiceSessionStatusSee States.
    activity"listening" | "thinking" | "speaking""listening"While live.
    levelsVoiceLevels | () => VoiceLevels{ user, agent }, 0 to 1. A function is read once a frame without re-rendering.
    muted, onMutedChangeboolean, (muted) => voidfalseMute shows when onMutedChange is passed. M toggles it.
    captions, onCaptionsChangeboolean, (on) => voidtrueShows or folds the transcript. C toggles it.
    devicesVoiceDevice[][]{ id, label, detail? }. The picker shows with at least one.
    deviceId, onDeviceChangestring, (id) => voidfirst device
    transcriptVoiceTranscriptItem[]See Transcript.
    startedAt, endedAtnumberDate.now() at connect and hang-up. Drive the timer and the summary's duration.
    errorstringerror: what happened and what it means for the call.
    slowAfternumber6000Ms of connecting before it says so.
    browser"chrome" | "safari" | "firefox" | "edge"detectedWhich steps show first when blocked.
    onInterrupt() => voidStop the agent's reply.
    onEnd() => voidEnd call; also Cancel while connecting and Not now.
    onRetry() => voidAsk for the mic again, or retry after error.
    summaryVoiceSummary{ decisions: string[], followUps: { id, text, owner?: "agent" | "user", due? }[] }.
    summaryError, onRetrySummarystring, () => void
    onCallAgain() => void
    classNamestring

    formatCallTime(ms) is exported too: 42000 gives "0:42".

    Transcript

    roleFieldsShows
    usertext, final?, at?Your words. final: false sets them in the subtle tone while the recognizer may revise them ("make the gallery"); final: true settles them into the text color as they're corrected ("make the galette").
    agenttext, spoken?, interrupted?, at?Pass the whole reply and how many characters the voice has reached (from your TTS's word or character timings). Each word fades in as it's said. interrupted ends the line with an "Interrupted" tag; what was never said stays unsaid.
    tooltitle, status, detail?, at?A lookup inline, in a quiet row: a turning arc while running, a check or a red "!" after. The title morphs from "Checking…" to "Checked…".

    A name heads each change of speaker, with at (ms from the start) as "0:42". The panel follows the newest line until the reader scrolls up, then offers "Latest".

    Levels

    The visual never invents movement. It eases toward whatever levels says, rising over --ui-dur-instant and falling over --ui-dur-panel, so it can't jitter, and each bar sways on its own slow sine so a steady voice still looks spoken. Silence is still. Feed it RMS from an AnalyserNode on each side:

    TypeScript
    function meter(stream: MediaStream) {  const ctx = new AudioContext();  const analyser = ctx.createAnalyser();  ctx.createMediaStreamSource(stream).connect(analyser);  const buf = new Float32Array(analyser.fftSize);  return () => {    analyser.getFloatTimeDomainData(buf);    const rms = Math.sqrt(buf.reduce((s, v) => s + v * v, 0) / buf.length);    return Math.min(1, Math.max(0, (20 * Math.log10(rms || 1e-8) + 55) / 45)); // -55…-10 dBFS  };}

    Who the disc shows: you, while your level is up (held 400ms across gaps between words, and ignored while muted); else the agent, while activity is speaking. You win a tie, so talking over the agent turns the disc to you at once. Stopping the agent is yours to do (onInterrupt or your client's own voice detection); mark its line interrupted and set activity back to listening.

    Customizing

    • The agent's color is --vs-agent (and --vs-agent-text) on the root, the text color by default. Set it to your brand's hue; yours is --vs-user, the accent.
    • The transcript's width is --vs-side (380px). Under 760px wide the transcript goes under the stage; under 560px Mute and Captions fold to icon capsules and the microphone takes its own row.
    • Copy. The labels are short on purpose; the hint line under them is where your product's voice goes. The denied steps name each browser's real menus; check them against the browsers you support.
    • The summary is whatever your agent writes after the call. Keep decisions to a line each and give follow-ups an owner, so the person can see what's theirs.

    Accessibility

    • The block is a section named "Call with Copper". A polite role="status" region speaks milestones only: connecting, connected, muted and unmuted, captions on and off, the agent stopping, the line dropping and coming back, an error, and the call's length when it ends. Never the levels or each word: the person can hear the call.
    • The transcript is a role="log" with aria-live="off", so it doesn't talk over the agent; it's focusable and scrolls from the keyboard. Each line has its speaker in words, interim lines say "(still listening)", a speaking line is aria-busy, and lookups say "In progress", "Done" or "Failed".
    • M and C work anywhere on the page except in a field, menu or picker; both buttons carry aria-keyshortcuts and show the key in their tooltip. Mute names its action ("Mute", "Unmute"); Captions is a toggle with aria-pressed.
    • When the action under the label goes (Interrupt after you interrupt), focus that was on it moves to Mute. After hanging up, focus moves to "Call ended".
    • Who's speaking and every state are in words; color only repeats them.
    • With reduced motion the bars don't move: they stay dots and brighten with the voice; the waiting wave becomes the dots breathing together; the disc doesn't swell; lines fade in without rising.

    Before / after

    Before: a pulsing circle and a hang-up button. Nobody can tell whether the agent heard, what it's doing, or why it went quiet.

    TSX
    <div className={speaking ? "orb pulse" : "orb"} /><button onClick={hangUp}>End</button>

    After: who's talking, what the agent is doing in words, what was heard and said, what it looked up, every failure with a way out, and what the call came to.

    TSX
    <VoiceSession  agentName="Copper"  status={call.status}  activity={call.activity}  levels={readLevels}  transcript={call.transcript}  startedAt={call.startedAt}  muted={muted}  onMutedChange={setMuted}  onInterrupt={call.cancelResponse}  onEnd={call.hangUp}  summary={call.summary}/>