Skip to content
Scrim UI

Voice Conversation UI

A voice conversation as it happens — who is speaking, the live transcript, and replay for any individual turn.

voiceconversationtranscriptspeaking

Preview

You0:02

Show me the streaming message component with a tool call in the middle

AI
Assistant0:04

Here it is — the tool row appears inline, then streaming resumes underneath.

You0:11

Can the tool row collapse once it finishes?

AI
Assistant0:13

Yes. Keep it open while running, collapse on success, stay open on error.

Presets

Props

-1

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-conversation

Follows 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-conversation

Component 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
voice-conversation.tsx
"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