Building a voice agent
A full call UI from four pieces: the agent-presence Voice Visualizer, the floating Voice Control Bar with its media keys, a Device Selector split control, and a Composer for typing instead. It's transport-agnostic — the prop shapes map onto LiveKit's useVoiceAssistant / useTrackToggle / useMediaDeviceSelect, with no runtime dependency and no permissions needed to run the demo.
Listening…
The pieces
- Voice Visualizer — the “is it live?” signal: dots driven by the agent
stateand audiolevel. - Voice Control Bar — the floating pill that holds the call keys and morphs open into a chat panel.
- Media Toggle + Device Selector — the mic/camera keys and the device split control.
- Composer — reused from the chat suite for the type-instead panel.
bunx --bun @seamui/cli@latest add voice-control-bar voice-visualizer media-toggle device-selector composer1. Agent presence
VoiceVisualizer takes a state (listening / thinking / speaking…) and either a numeric level (0–1) or a track that its owned useAudioLevel hook analyses. Each state has its own motion, and every channel maps to opacity under reduced motion — a liveness signal must never freeze.
<VoiceVisualizer state={state} level={level} size="lg" count={7} />
<VoiceVisualizerCaption>{caption}</VoiceVisualizerCaption>2. The control pill
VoiceControlBar is a raised pill at overlay depth. Drop the call keys into VoiceControlBarActions: a MediaToggle and DeviceSelector cluster into one debossed well as a split control (mute is one press; picking the device is the small key beside it), and VoiceControlBarEnd is the destructive-soft hang-up key.
<VoiceControlBarActions>
<div className="bg-muted flex items-center gap-1 rounded-full p-1 shadow-well">
<MediaToggle kind="mic" defaultPressed className="size-8" />
<DeviceSelector devices={mics} value={mic} onValueChange={setMic}>
<DeviceSelectorTrigger />
<DeviceSelectorContent />
</DeviceSelector>
</div>
<MediaToggle kind="camera" />
<VoiceControlBarTrigger><MessageSquare /></VoiceControlBarTrigger>
<VoiceControlBarEnd />
</VoiceControlBarActions>3. Expand to chat
The chat trigger morphs the pill open: it grows a VoiceControlBarPanel holding a Composer and squares off from a pill into a card. The morph is a height/radius transition (the sanctioned duration case) that snaps rather than freezes under reduced motion.
<VoiceControlBar>
<VoiceControlBarPanel>
<Composer onSubmit={send}>
<ComposerTextarea placeholder="Type instead…" />
<ComposerToolbar><ComposerSubmit /></ComposerToolbar>
</Composer>
</VoiceControlBarPanel>
<VoiceControlBarActions>…</VoiceControlBarActions>
</VoiceControlBar>4. Wire it up
Feed the visualizer a state and level, and the device selector its list. Here a fake agent cycles the states and wobbles the level so it breathes without a real track; swapping in LiveKit means passing useVoiceAssistant()'s state and audioTrack straight through — the components don't change.
"use client"
import * as React from "react"
import { MessageSquare } from "lucide-react"
import {
VoiceVisualizer,
VoiceVisualizerCaption,
} from "@/registry/seam/ui/voice-visualizer"
import {
VoiceControlBar,
VoiceControlBarActions,
VoiceControlBarPanel,
VoiceControlBarTrigger,
VoiceControlBarEnd,
} from "@/registry/seam/ui/voice-control-bar"
import {
Composer,
ComposerTextarea,
ComposerToolbar,
ComposerSubmit,
} from "@/registry/seam/ui/composer"
import {
DeviceSelector,
DeviceSelectorContent,
DeviceSelectorTrigger,
} from "@/registry/seam/ui/device-selector"
import { MediaToggle } from "@/registry/seam/ui/media-toggle"
type State = "listening" | "thinking" | "speaking"
const MICS = [
{ deviceId: "default", label: "MacBook Pro Microphone" },
{ deviceId: "airpods", label: "AirPods Pro" },
]
// A fake agent: cycles listening → thinking → speaking, and while speaking
// emits a wobbling level so the visualizer breathes without a real track. No
// timers on the server — everything runs in an effect after mount.
function useFakeAgent() {
const [state, setState] = React.useState<State>("listening")
const [level, setLevel] = React.useState(0)
const stateRef = React.useRef<State>("listening")
stateRef.current = state
React.useEffect(() => {
const order: State[] = ["listening", "thinking", "speaking"]
let i = 0
const step = window.setInterval(() => {
i = (i + 1) % order.length
setState(order[i])
}, 2600)
let raf = 0
let t = 0
const tick = () => {
t += 0.08
setLevel(
stateRef.current === "speaking" ? 0.4 + Math.abs(Math.sin(t)) * 0.5 : 0
)
raf = requestAnimationFrame(tick)
}
tick()
return () => {
window.clearInterval(step)
cancelAnimationFrame(raf)
}
}, [])
return { state, level }
}
const CAPTION: Record<State, string> = {
listening: "Listening…",
thinking: "Thinking…",
speaking: "How can I help you today?",
}
// The capstone: a full voice-agent widget — a state-driven visualizer with a
// caption above the floating control pill, which expands into a text composer.
export default function VoiceWidgetDemo() {
const { state, level } = useFakeAgent()
const [mic, setMic] = React.useState("default")
return (
<div className="flex flex-col items-center gap-8 py-4">
<div className="flex flex-col items-center gap-4">
<VoiceVisualizer state={state} level={level} size="lg" count={7} />
<VoiceVisualizerCaption>{CAPTION[state]}</VoiceVisualizerCaption>
</div>
<VoiceControlBar className="w-full max-w-xs">
<VoiceControlBarPanel>
<Composer
onSubmit={(e) => e.preventDefault()}
className="shadow-none"
>
<ComposerTextarea placeholder="Type instead…" />
<ComposerToolbar>
<ComposerSubmit />
</ComposerToolbar>
</Composer>
</VoiceControlBarPanel>
<VoiceControlBarActions className="justify-center">
<div className="bg-muted flex items-center gap-1 rounded-full p-1 shadow-well">
<MediaToggle kind="mic" defaultPressed className="size-8" />
<DeviceSelector devices={MICS} value={mic} onValueChange={setMic}>
<DeviceSelectorTrigger />
<DeviceSelectorContent />
</DeviceSelector>
</div>
<MediaToggle kind="camera" />
<VoiceControlBarTrigger>
<MessageSquare className="size-4" />
</VoiceControlBarTrigger>
<VoiceControlBarEnd />
</VoiceControlBarActions>
</VoiceControlBar>
</div>
)
}Accessibility & motion
The visualizer is a role="status" labelled from its state; the caption is the visible equivalent. Off/ending states — muted mic, camera off, END — are destructive-tinted and carry a non-color signal (a slashed icon, a label), never color alone. Under reduced motion every animated channel becomes opacity, so the “is it live?” signal stays alive.
Next steps
- Swap the fake agent for a real transport (LiveKit, or your own WebRTC) — only the
state/level/devicessources change. - Feed a live mic
trackinto the Voice Visualizer and Voice Avatar. - Enumerate real hardware with the Device Selector's owned
useMediaDeviceshook.