Voice Chat
A realtime voice session panel — speaking orb, word-by-word live captions, mute, mic picker, connection meter, session clock, and an end-of-call summary.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/voice-chat.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "VoiceChat" block: the entire surface of a
realtime voice session — a speaking orb, a live caption line, the mic controls,
and the summary that replaces all three when the call is over. lucide-react for
glyphs; shadcn button / dropdown-menu / alert-dialog primitives; a
useControllableState hook for the value / defaultValue / onChange triads. No
audio APIs of its own: the transport belongs to the host, this renders it.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>. The
root spreads the rest and carries data-state / data-speaker / data-quality so
a parent can style around the session without prop drilling.
- state?: "connecting" | "active" | "reconnecting" | "ended",
defaultState = "connecting", onStateChange?(state) — controlled or not, one
machine either way.
- assistantName?: string = "Assistant".
- speaker?: "user" | "assistant" | "none" = "none" — who holds the floor.
- transcript?: string — caption text the recognizer has SETTLED.
- interim?: string — the unsettled tail; every new word fades in.
- level?: number (0..1) — measured amplitude. Omit for the built-in envelope.
- muted?, defaultMuted? = false, onMutedChange?(muted).
- devices?: { id: string; label: string }[] = [], deviceId?, defaultDeviceId?,
onDeviceChange?(deviceId). The host enumerates devices; this only picks one.
- quality?: "excellent" | "good" | "poor" | "lost" = "excellent".
- durationMs?: number — controlled clock. Omit and the panel runs its own.
- confirmEnd?: boolean = true, onEnd?(durationMs) — at most once per session.
- onRestart?: () => void, transcriptSlot?: ReactNode, summaryExtra?: ReactNode.
Behavior — the session machine
- Four first-class branches, not opacity tweaks on one layout: connecting and
reconnecting keep the live panel with a spinner in the orb, active is the
normal case, ended REPLACES orb + caption + controls with a summary. A greyed
out live panel with a dead End button is the failure mode to avoid.
- The clock counts only while the session is active or reconnecting: a
reconnect is still call time, a connecting handshake is not. Measure each
running span with performance.now() and fold it into an accumulator when the
span closes; the interval (~250 ms) only decides how often the number is
repainted, it never counts. A throttled background tab, a re-render storm or a
pause at ended can then never make a session look shorter than it was.
- Ending is ONE-SHOT. The confirm dialog, a connecting-state cancel and a
controlled owner can all land on the same hang-up, so onEnd sits behind a
boolean lock rather than behind three careful conditions, and its payload is
computed by closing the span in flight by hand — not read off the last render,
which can be a quarter second stale.
- confirmEnd opens an AlertDialog carrying the elapsed time; a session still
CONNECTING skips it entirely and the button reads "Cancel" — there is nothing
to lose yet, and a confirm dialog over an empty session is friction.
- The dialog's open state is DERIVED (open && !ended), not synced by an effect:
a session that ends anywhere else closes it on the same render.
- A transition out of ended is a new session: the hang-up lock lifts and the
clock resets, whether the restart came from this panel's button or from an
owner swapping the prop. The restart button only renders when it can actually
do something — onRestart given, or the panel owns its state.
- Nothing fires from unmount. onEnd means "the user hung up", and a component
leaving the tree is not the user hanging up. Cleanup clears timers only.
Behavior — floor, orb and amplitude
- A muted user cannot be the one speaking, whatever the transport reports, and
only an active session has a floor at all: derive the effective speaker rather
than trusting the prop, or the orb will glow "you are talking" at a dead mic.
- The orb is a halo, a ring, optional ripples and a core. Amplitude is written
straight to the halo's and the ring's style.transform inside a rAF loop, never
through setState: sixty re-renders a second of the caption, the clock and the
controls is too much to pay for a circle. The two nodes use different gains so
they never read as one blob.
- With no level prop, drive it from a deterministic three-sine envelope, shaped
per voice: synthesised speech runs steadier, a person breathes between
phrases. Deterministic, so a screenshot of a live session is stable.
- prefers-reduced-motion: the envelope does not run at all — the orb is pinned
to a static, clearly larger scale while somebody speaks, and the ripples are
motion-reduce:hidden. A REAL level keeps driving it, quantised to four steps,
because measured amplitude is information rather than decoration.
Behavior — captions
- Settled text renders solid, interim text renders muted, in one line. Keep only
the tail of the settled text (~260 chars, cut at a word boundary, leading
ellipsis): a caption line is a window on the newest words, the archive belongs
in the transcript.
- Key each interim word by index AND content so appended words mount fresh and
fade in exactly once, while a revised word re-mounts and re-animates. Old
words keep their identity and never flicker.
- The visible line is NOT a live region. Announcing every partial restarts a
screen reader mid-word several times a second. Two quiet sr-only regions
instead: one polite role=status for session and control transitions, one for
settled caption phrases — and only the delta, so a growing transcript is not
re-read from the top. Clear the status region after ~4 s so the same message
can be announced twice in a row.
- Empty-caption placeholders are per state: connecting names the callee,
reconnecting says audio is paused, muted says how to get back.
Behavior — controls
- Mute swaps the orb tone, the caption speaker label and its own label. Its
LABEL states the action ("Mute" / "Unmute"), so it must NOT also carry
aria-pressed — "Unmute, pressed" is exactly the confusion the APG warns
about; pick one. "m" toggles it from anywhere inside the panel
(aria-keyshortcuts on the button), unless the keystroke lands in an input,
textarea, select or contenteditable, where "m" means the letter m.
- The device picker is a DropdownMenu radio group over the devices prop. An
empty list DISABLES the trigger instead of hiding it, so the affordance does
not appear and vanish while permission is being granted. Switching says so in
an inline notice that clears itself after ~4.5 s — a picker with no visible
downstream effect is a dead control.
- Quality is a four-bar meter with role=img and a spoken label; poor and lost
paint destructive, and reconnecting makes the meter pulse. Reconnecting also
raises a role=status banner: audio paused, clock still running.
Rendering & styling
- Semantic tokens only: bg-card panel, bg-muted quiet core, primary for the
assistant's voice and the live pill, var(--chart-2) for the user's voice,
destructive for weak signal / reconnect banner / End, muted-foreground for
supporting copy, ring-ring focus. No hex, no rgb(), no oklch().
- The orb accent is an INLINE CSS color (var(--primary) / var(--chart-2) /
var(--muted-foreground)) rather than a utility class, because the two voices
have to be distinguishable at a glance and one accent token cannot do that.
- Everything else merges through cn(). Keyframes (word fade, ripple, beat) ship
with the component in a hoisted <style href precedence> tag.
- Header row wraps: name + state pill on the left, quality + mm:ss (tabular-nums,
role=timer with a spoken aria-label) on the right. Controls wrap into a
centered row. The caption box reserves its height so a settling phrase does
not bounce the controls.
Customization levers
- Density: the orb is size-40 with a size-24 core — drop both by a third for a
sidebar panel, or hide the header row entirely for a full-screen call view.
- Sub-blocks: quality meter, device picker, notice line, summary rows are each
one JSX block; remove what your product does not have (a phone-less web widget
rarely shows "connection"), the machine does not care.
- Motion strength: HALO_GAIN / RING_GAIN scale the amplitude travel, and the
ripple pair is decorative — delete it for a calmer orb.
- Palette: swap var(--chart-2) for another chart token to re-key the user's
voice, or make both voices primary and tell them apart by the glyph alone.
- Amplitude source: pass level from an AnalyserNode (getByteTimeDomainData ->
RMS -> 0..1) for a truthful orb; leave it off for the envelope.
- Transcript policy: keep interim in the panel and settle it yourself, or route
both through your recogniser and hand this component only the final text.
- Endings: confirmEnd={false} for a one-tap hang-up, transcriptSlot for a link
to the recording, summaryExtra for a rating widget or a follow-up CTA.
- Copy: every string lives in module-scope records (state labels, announcements,
quality labels) — translating the panel is editing them, not threading a
labels object through the props.Concepts
- Floor, not flags — "who is speaking" is one derived value with three answers, and it is derived rather than trusted: a muted microphone and a session that is not active both mean nobody holds the floor, so the orb can never glow "you are talking" at a dead mic.
- Clock as accumulator — session length is measured in wall-clock spans folded into a running total, so a reconnect, a background tab or a paused render loop changes how often the number is repainted but never what it counts.
- One-shot hang-up — the confirm dialog, the connecting-state cancel and a controlled owner can all end the same session, so the hand-off sits behind a single lock; the lock lifts only when a new session opens, which is also what resets the clock.
- Confirm only when there is something to lose — a session still connecting is cancelled outright, an active one asks first with its elapsed time in the question. Friction is spent where the user could actually lose a conversation.
- Quiet captions — the visible caption line is deliberately not a live region: interim words are for the eye, while a separate polite region announces only settled phrases, and only the part that is new, so a screen reader is never restarted mid-word.
- Visible downstream effect — muting stops the user's turn, switching microphone names the new one in the panel, dropping the connection paints the banner and pulses the meter: every control changes something the user can see, or it would not be a control.
Voice Orb
A voice-session orb — layered token gradients that breathe when idle, ripple to a 0..1 amplitude while listening, swirl while thinking and pulse while speaking.
Live Captions
A broadcast-style caption bar for a live recogniser feed — interim words render muted and solidify when finalised, a rolling two-line window follows the speech, speaker chips carry the turn, and the panel hides itself after silence.