Elements

86 / 122 · Voice

Voice conversation

A live call: the orb tracks your voice, the caption names the turn, the transcript follows.

ConnectingOpening the mic

Installation

npx shadcn@latest add "@assistant-ui/elements-voice-conversation"

Usage

import { VoiceConversation } from "@/components/elements/voice-conversation";

<VoiceConversation
  mode="listening"
  amplitude={0.6}
  transcript={transcript}
  onToggleMute={toggleMute}
  onEnd={hangUp}
/>

Props

mode*"connecting" | "listening" | "thinking" | "speaking"Drives the orb and the caption. Only listening and speaking react to amplitude.
amplitude*numberCurrent level from 0 to 1, clamped internally. Feed it from the analyser node.
transcript*VoiceTurn[]Turns so far, oldest first.
mutedbooleanMarks the mic control as engaged and swaps the hint.
onToggleMute() => voidCalled when the mic is muted or unmuted.
onInterrupt() => voidCalled when the orb is pressed while the assistant is speaking. The orb is inert in every other mode, so the caption only offers the interrupt when one is possible.
onEnd() => voidCalled when the call is ended.
classNamestringExtra classes merged onto the root.

Source

voice-conversation.tsx
"use client";

import { MicIcon, MicOffIcon, PhoneOffIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { ghostButton, mono, paper } from "./surfaces";

export type VoiceMode = "connecting" | "listening" | "thinking" | "speaking";

export interface VoiceTurn {
  id: string;
  role: "user" | "assistant";
  text: string;
}

const CAPTION: Record<VoiceMode, string> = {
  connecting: "Connecting",
  listening: "Listening",
  thinking: "Thinking",
  speaking: "Speaking",
};

const HINT: Record<VoiceMode, string> = {
  connecting: "Opening the mic",
  listening: "Listening for you",
  thinking: "Working on it",
  speaking: "Playing the reply",
};

export function VoiceConversation({
  mode,
  amplitude,
  transcript,
  muted,
  onToggleMute,
  onInterrupt,
  onEnd,
  className,
}: {
  mode: VoiceMode;
  amplitude: number;
  transcript: readonly VoiceTurn[];
  muted?: boolean;
  onToggleMute?: () => void;
  onInterrupt?: () => void;
  onEnd?: () => void;
  className?: string;
}) {
  const level = Math.max(0, Math.min(1, amplitude));
  const active = mode === "listening" || mode === "speaking";
  const canInterrupt = mode === "speaking" && onInterrupt !== undefined;

  return (
    <div
      className={cn(
        paper,
        "flex w-full max-w-xs flex-col items-center gap-4 rounded-[28px] px-5 py-5",
        className,
      )}
    >
      <button
        type="button"
        onClick={onInterrupt}
        disabled={!canInterrupt}
        aria-label="Interrupt the assistant"
        className="focus-visible:ring-foreground/20 relative flex size-24 items-center justify-center rounded-full outline-none focus-visible:ring-2 disabled:cursor-default"
      >
        <span
          aria-hidden
          className={cn(
            "absolute rounded-full transition-[transform,opacity] duration-200 ease-out motion-reduce:transition-none",
            mode === "speaking"
              ? "bg-blue-500/12 dark:bg-blue-400/15"
              : "bg-foreground/[0.05]",
          )}
          style={{
            width: "6rem",
            height: "6rem",
            transform: `scale(${active ? 0.72 + level * 0.28 : 0.62})`,
            opacity: active ? 1 : 0.5,
          }}
        />
        <span
          aria-hidden
          className={cn(
            "absolute rounded-full transition-[transform,opacity] duration-150 ease-out motion-reduce:transition-none",
            mode === "speaking"
              ? "bg-blue-500/20 dark:bg-blue-400/25"
              : "bg-foreground/[0.08]",
          )}
          style={{
            width: "4.25rem",
            height: "4.25rem",
            transform: `scale(${active ? 0.8 + level * 0.22 : 0.7})`,
          }}
        />
        <span
          aria-hidden
          className={cn(
            "relative size-10 rounded-full transition-[transform,background-color] duration-150 ease-out motion-reduce:transition-none",
            mode === "connecting" && "bg-foreground/20 animate-pulse",
            mode === "listening" && "bg-foreground/80",
            mode === "thinking" && "bg-foreground/30 animate-pulse",
            mode === "speaking" && "bg-blue-500 dark:bg-blue-400",
          )}
          style={{ transform: `scale(${active ? 0.9 + level * 0.2 : 0.85})` }}
        />
      </button>

      <div className="flex flex-col items-center gap-1">
        <span className="text-[13.5px] font-medium">{CAPTION[mode]}</span>
        <span className={cn(mono, "text-foreground/35")}>
          {muted ? "Mic off" : canInterrupt ? "Tap to interrupt" : HINT[mode]}
        </span>
      </div>

      <div className="flex min-h-[4.5rem] w-full flex-col gap-1.5">
        {transcript.map((turn) => (
          <div
            key={turn.id}
            className="fade-in slide-in-from-bottom-1 animate-in fill-mode-both flex gap-2 text-xs leading-relaxed duration-300"
          >
            <span
              className={cn(
                mono,
                "w-8 shrink-0",
                turn.role === "user"
                  ? "text-foreground/30"
                  : "text-blue-500/70 dark:text-blue-400/70",
              )}
            >
              {turn.role === "user" ? "you" : "ai"}
            </span>
            <span
              className={cn(
                "min-w-0 flex-1",
                turn.role === "user"
                  ? "text-foreground/50"
                  : "text-foreground/80",
              )}
            >
              {turn.text}
            </span>
          </div>
        ))}
      </div>

      <div className="flex items-center gap-2">
        <button
          type="button"
          aria-label={
            muted ? "Turn the microphone on" : "Turn the microphone off"
          }
          aria-pressed={muted}
          onClick={onToggleMute}
          disabled={!onToggleMute}
          className={cn(
            ghostButton,
            "size-10 disabled:pointer-events-none disabled:opacity-30",
            muted && "bg-foreground/[0.08] text-foreground/90",
          )}
        >
          {muted ? (
            <MicOffIcon className="size-4" />
          ) : (
            <MicIcon className="size-4" />
          )}
        </button>
        <button
          type="button"
          aria-label="End the call"
          onClick={onEnd}
          disabled={!onEnd}
          className="flex size-10 items-center justify-center rounded-full bg-red-500/90 text-white transition-[opacity,scale] duration-150 hover:opacity-90 active:scale-[0.96] disabled:pointer-events-none disabled:opacity-30 motion-reduce:transition-none"
        >
          <PhoneOffIcon className="size-4" />
        </button>
      </div>
    </div>
  );
}