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 state and audio level.
  • 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.
bash
bunx --bun @seamui/cli@latest add voice-control-bar voice-visualizer media-toggle device-selector composer

1. 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.

tsx
<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.

tsx
<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.

tsx
<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.

tsx
"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/devices sources change.
  • Feed a live mic track into the Voice Visualizer and Voice Avatar.
  • Enumerate real hardware with the Device Selector's owned useMediaDevices hook.