Skip to content
Scrim UI

Voice Input UI

A microphone button that expands into a recording panel — live waveform, a running transcript, and cancel or send.

voiceinputrecordingmicrophone

Preview

Click to talk

Presets

Props

Also takes onStart, onStop, onCancel.

Agent promptClaude Code · Cursor · any agent
Add the Voice Input UI component from the Scrim UI registry to this project and use it in the configuration described below.

Voice Input UI — A microphone button that expands into a recording panel — live waveform, a running transcript, and cancel or send.

## 1. Install

```bash
npx shadcn@latest add @scrimui/voice-input
```

This writes a single file to `components/ui/voice-input.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-input to the same path by hand; nothing in the file depends on shadcn.

## 2. Use it

```tsx
import { VoiceInput } from "@/components/ui/voice-input";

<VoiceInput
  state="idle"
  recordingTime="0:07"
  transcript="Show me the streaming message component with a tool call in the middle"
  onStart={() => {}}
  onStop={() => {}}
  onCancel={() => {}}
/>
```

## 3. The configuration that matters

This is the component's "Idle" state — A single microphone button; pressing it expands the control into recording.

Every prop is at its default value. Keep the call site minimal — do not write out default values.

## 4. Wire the handlers

`onStart`, `onStop`, `onCancel` are stubs in the snippet above (`() => {}`). Connect them to this project's own state and data layer instead of shipping the empty functions.

Reference: https://scrimui.dev/components/voice-input

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

Component source

React + Tailwind component using shadcn/ui (button, card). The install command adds these dependencies automatically.

When copying source manually, install the required primitives first:

npx shadcn@latest add button card
voice-input.tsx
"use client";

import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import * as React from "react";

/* ------------------------------------------------------------------ */
/* Types                                                               */
/* ------------------------------------------------------------------ */

export type VoiceInputState = "idle" | "recording";

export type VoiceInputProps = {
  state?: VoiceInputState;
  onStart?: () => void;
  onStop?: () => void;
  onCancel?: () => void;
  recordingTime?: string;
  transcript?: string;
  className?: string;
};

/* ------------------------------------------------------------------ */
/* Icons                                                               */
/* ------------------------------------------------------------------ */

function MicIcon(props: React.SVGProps<SVGSVGElement>) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      strokeLinecap="round"
      strokeLinejoin="round"
      width="16"
      height="16"
      {...props}
    >
      <path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" />
      <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
      <path d="M12 19v3" />
    </svg>
  );
}

function StopIcon(props: React.SVGProps<SVGSVGElement>) {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" width="11" height="11" {...props}>
      <rect x="6" y="6" width="12" height="12" rx="2" />
    </svg>
  );
}

function XIcon(props: React.SVGProps<SVGSVGElement>) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      strokeLinecap="round"
      strokeLinejoin="round"
      width="12"
      height="12"
      {...props}
    >
      <path d="M18 6 6 18M6 6l12 12" />
    </svg>
  );
}

/* ------------------------------------------------------------------ */
/* Bars                                                               */
/* ------------------------------------------------------------------ */

function Bars({ active }: { active: boolean }) {
  return (
    <>
      <style>{`@keyframes aiui-bar{0%,100%{transform:scaleY(.35)}50%{transform:scaleY(1)}}`}</style>
      <div className="flex h-5 items-center gap-[3px]" aria-hidden>
        {Array.from({ length: 9 }).map((_, i) => (
          <span
            key={i}
            className="w-[3px] rounded-full bg-current"
            style={{
              height: "100%",
              transform: "scaleY(.35)",
              transformOrigin: "center",
              animation: active ? `aiui-bar 0.8s ease-in-out ${i * 0.06}s infinite` : "none",
              opacity: active ? 1 : 0.3,
            }}
          />
        ))}
      </div>
    </>
  );
}

/* ------------------------------------------------------------------ */
/* VoiceInput                                                          */
/* ------------------------------------------------------------------ */

export function VoiceInput({
  state = "idle",
  onStart,
  onStop,
  onCancel,
  recordingTime = "0:07",
  transcript = "",
  className = "",
}: VoiceInputProps) {
  if (state === "recording") {
    return (
      <Card
        className={`gap-0 py-0 rounded-xl border border-red-200 bg-card p-3 dark:border-red-900/50 ${className}`}
      >
        <div className="flex items-center gap-3">
          <Button variant="destructive" size="icon-sm"
            type="button"
            onClick={onStop}
            aria-label="Stop recording"
            className="min-h-6 min-w-6 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-red-500 text-white transition-transform"
          >
            <StopIcon />
          </Button>
          <div className="min-w-0 flex-1 text-red-500 dark:text-red-400">
            <Bars active />
          </div>
          <span className="shrink-0 text-xs tabular-nums text-muted-foreground">{recordingTime}</span>
          {onCancel && (
            <Button variant="ghost" size="icon-sm"
              type="button"
              onClick={onCancel}
              aria-label="Cancel recording"
              className="min-h-6 min-w-6 shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-muted-foreground"
            >
              <XIcon />
            </Button>
          )}
        </div>
        <p className="mt-2 text-sm text-muted-foreground">
          {transcript || "Listening…"}
        </p>
      </Card>
    );
  }

  return (
    <Card
      className={`flex-row gap-0 py-0 flex items-center gap-3 rounded-xl border border-border bg-card p-3 ${className}`}
    >
      <Button variant="default" size="icon-sm"
        type="button"
        onClick={onStart}
        aria-label="Start voice input"
        className="min-h-6 min-w-6 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-transform"
      >
        <MicIcon />
      </Button>
      <span className="text-sm text-muted-foreground">Click to talk</span>
      <div className="ml-auto text-muted-foreground">
        <Bars active={false} />
      </div>
    </Card>
  );
}

When to use it

  • Offer voice next to text, never instead of it — some users always prefer typing.
  • Make the recording state unmistakable: red accent, live waveform, elapsed time.
  • Show a live transcript as speech is captured so users can catch mis-transcriptions early.
  • Provide Stop (finish) and Cancel (discard) as separate, clearly labeled actions.

What breaks in production

  • A microphone icon with no hint of what happens when you click it.
  • Starting to record with no visible change — the user is unsure whether they are being recorded.
  • No way to discard a recording; users stay stuck with a half-spoken message.

Guides

Related Components