Browse all components
Voice Conversation UI
A voice conversation as it happens — who is speaking, the live transcript, and replay for any individual turn.
Preview
Show me the streaming message component with a tool call in the middle
Here it is — the tool row appears inline, then streaming resumes underneath.
Can the tool row collapse once it finishes?
Yes. Keep it open while running, collapse on success, stay open on error.
Presets
Props
Also takes onReplay.
Agent promptClaude Code · Cursor · any agent
Add the Voice Conversation UI component from the Scrim UI registry to this project and use it in the configuration described below.
Voice Conversation UI — A voice conversation as it happens — who is speaking, the live transcript, and replay for any individual turn.
## 1. Install
```bash
npx shadcn@latest add @scrimui/voice-conversation
```
This writes a single file to `components/ui/voice-conversation.tsx`. It is plain React + Tailwind with no runtime dependencies — no Radix, no CVA, nothing to add to package.json. If this project does not use the shadcn CLI, copy the source from https://scrimui.dev/components/voice-conversation to the same path by hand; nothing in the file depends on shadcn.
## 2. Use it
```tsx
import { VoiceConversation } from "@/components/ui/voice-conversation";
const TURNS = [
{ id: "t1", role: "user", text: "Show me the streaming message component with a tool call in the middle", time: "0:02" },
{ id: "t2", role: "assistant", text: "Here it is — the tool row appears inline, then streaming resumes underneath.", time: "0:04" },
{ id: "t3", role: "user", text: "Can the tool row collapse once it finishes?", time: "0:11" },
{ id: "t4", role: "assistant", text: "Yes. Keep it open while running, collapse on success, stay open on error.", time: "0:13" },
];
<VoiceConversation
speaking={-1}
turns={TURNS}
onReplay={() => {}}
/>
```
## 3. The configuration that matters
This is the component's "Transcript" state — Alternating turns with timestamps and a replay control on each.
Every prop is at its default value. Keep the call site minimal — do not write out default values.
## 4. Wire the handlers
`onReplay` is a stub in the snippet above (`() => {}`). Connect it to this project's own state and data layer instead of shipping the empty function.
Reference: https://scrimui.dev/components/voice-conversationFollows the props below — change a control and the prompt changes with it, so an agent reproduces that configuration instead of the defaults.
Install
npx shadcn@latest add @scrimui/voice-conversationComponent source
React + Tailwind component using shadcn/ui (button). The install command adds these dependencies automatically.
When copying source manually, install the required primitives first:
npx shadcn@latest add button"use client";
import { Button } from "@/components/ui/button";
import * as React from "react";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export type VoiceTurn = {
id: string;
role: "user" | "assistant";
text: string;
time?: string;
speaking?: boolean;
};
export type VoiceConversationProps = {
turns: VoiceTurn[];
onReplay?: (id: string) => void;
className?: string;
};
/* ------------------------------------------------------------------ */
/* Icons */
/* ------------------------------------------------------------------ */
function UserIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
width="14"
height="14"
{...props}
>
<circle cx="12" cy="8" r="4" />
<path d="M4 21a8 8 0 0 1 16 0" />
</svg>
);
}
function PlayIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" width="11" height="11" {...props}>
<path d="M7 4.5v15a1 1 0 0 0 1.52.85l12-7.5a1 1 0 0 0 0-1.7l-12-7.5A1 1 0 0 0 7 4.5z" />
</svg>
);
}
/* ------------------------------------------------------------------ */
/* Speaking indicator */
/* ------------------------------------------------------------------ */
function SpeakingIcon() {
return (
<>
<style>{`@keyframes aiui-spk{0%,100%{transform:scaleY(.3)}50%{transform:scaleY(1)}}`}</style>
{/* role="img" is what makes aria-label legal here: on a bare <span> with
no role, aria-label is a prohibited attribute and is ignored, so the
animated bars had no accessible name at all. */}
<span
role="img"
aria-label="Speaking"
className="inline-flex h-3.5 items-center gap-[2px] text-muted-foreground"
>
{[0, 1, 2, 3].map((i) => (
<span
key={i}
className="w-[2px] rounded-full bg-current"
style={{
height: "100%",
transform: "scaleY(.3)",
transformOrigin: "center",
animation: `aiui-spk 0.6s ease-in-out ${i * 0.12}s infinite`,
}}
/>
))}
</span>
</>
);
}
/* ------------------------------------------------------------------ */
/* VoiceConversation */
/* ------------------------------------------------------------------ */
export function VoiceConversation({
turns,
onReplay,
className = "",
}: VoiceConversationProps) {
return (
<div className={`space-y-3 ${className}`}>
{turns.map((turn) => {
const user = turn.role === "user";
return (
<div key={turn.id} className={`flex items-start gap-3 ${user ? "flex-row-reverse" : ""}`}>
<span
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-semibold ${
user
? "bg-muted text-muted-foreground"
: "bg-primary text-primary-foreground"
}`}
>
{user ? <UserIcon /> : "AI"}
</span>
<div
className={`min-w-0 max-w-[75%] rounded-xl px-4 py-3 ${
user
? "bg-muted text-foreground"
: "bg-transparent text-foreground"
}`}
>
<div
className={`flex items-center gap-2 text-xs ${
user ? "text-muted-foreground" : "text-muted-foreground"
}`}
>
<span className="font-medium">{user ? "You" : "Assistant"}</span>
{turn.speaking && <SpeakingIcon />}
{turn.time && <span className="tabular-nums">{turn.time}</span>}
{!turn.speaking && onReplay && (
<Button variant="ghost" size="icon-sm"
type="button"
onClick={() => onReplay(turn.id)}
aria-label={`Replay ${user ? "your" : "the assistant's"} message`}
className="min-h-6 min-w-6 rounded-md p-0.5 transition-colors hover:text-muted-foreground"
>
<PlayIcon />
</Button>
)}
</div>
<p className="mt-1 text-sm leading-6">{turn.text}</p>
</div>
</div>
);
})}
</div>
);
}
When to use it
- Show who is speaking with an animated waveform — silence reads as 'offline' at a glance.
- Offer per-turn replay; voice is ephemeral and users will want to re-hear a number.
- Keep timestamps so users can judge length before replaying a long answer.
- Mirror the layout for the user's turns (right-aligned) to match chat conventions.
What breaks in production
- A transcript with no way to re-hear anything — the whole point of voice is lost.
- Animating every turn at once, so the speaking indicator carries no information.
- No visual distinction between user and assistant turns.
Related Components
A microphone button that expands into a recording panel — live waveform, a running transcript, and cancel or send.
An animated audio waveform — distinct bar motion for listening, recording, and the assistant speaking back.
The chrome around a live voice call — an unmistakable mute toggle, a running call timer, and an end button that's always one tap away.
The message input at the heart of an AI chat app — file attachments, model picker, tool toggles, voice, and a send button that turns into stop.