Voice Conversation UI
A voice conversation transcript with speaking indicators and per-turn replay.
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.
Props
Also takes onReplay.
Component source
Single-file React + Tailwind component. No dependencies — drop it into any project with Tailwind configured.
"use client";
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="2"
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-violet-500"
>
{[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-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400"
: "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
}`}
>
{user ? <UserIcon /> : "AI"}
</span>
<div
className={`min-w-0 max-w-[75%] rounded-2xl px-4 py-3 ${
user
? "rounded-tr-md bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "rounded-tl-md border border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-800/60"
}`}
>
<div
className={`flex items-center gap-2 text-[11px] ${
user ? "text-zinc-300 dark:text-zinc-600" : "text-zinc-500 dark:text-zinc-400"
}`}
>
<span className="font-medium">{user ? "You" : "Assistant"}</span>
{turn.speaking && <SpeakingIcon />}
{turn.time && <span className="tabular-nums">{turn.time}</span>}
{!turn.speaking && onReplay && (
<button
type="button"
onClick={() => onReplay(turn.id)}
aria-label={`Replay ${user ? "your" : "the assistant's"} message`}
className="rounded-md p-0.5 transition-colors hover:text-zinc-600 dark:hover:text-zinc-300"
>
<PlayIcon />
</button>
)}
</div>
<p className="mt-1 text-sm leading-6">{turn.text}</p>
</div>
</div>
);
})}
</div>
);
}
Usage Guidelines
- 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.
Common UX Mistakes
- 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 voice input control that expands into a recording panel with waveform and transcript.
Animated waveform bars with listening, recording and speaking states.
The core input for AI chat products — with attachments, model selector, tools, voice and loading states.
Prompt input with file attachment chips, upload progress and removal.