Skip to content
Scrim UI

Voice Waveform UI

An animated audio waveform — distinct bar motion for listening, recording, and the assistant speaking back.

voicewaveformaudioindicator

Preview

Presets

Props

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

Voice Waveform UI — An animated audio waveform — distinct bar motion for listening, recording, and the assistant speaking back.

## 1. Install

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

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

## 2. Use it

```tsx
import { VoiceWaveform } from "@/components/ui/voice-waveform";

<VoiceWaveform
  state="idle"
  bars={24}
/>
```

## 3. The configuration that matters

This is the component's "Idle" state — Flat and dim — nothing is happening, and the UI says so honestly.

These props were set deliberately and should be preserved as written:

- `state` = "idle" — State

Every other prop is at its default; leave those off the call site rather than writing the default value out.

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

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

Component source

React + Tailwind component using your shadcn theme. No additional component dependencies.

voice-waveform.tsx
"use client";

import * as React from "react";

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

export type WaveformState = "idle" | "listening" | "recording" | "speaking";

export type VoiceWaveformProps = {
  state?: WaveformState;
  bars?: number;
  className?: string;
};

/* Bars animate with the same keyframe; the state picks the speed,
   opacity and color (color comes from the parent via currentColor). */

const DURATION: Record<WaveformState, number> = {
  idle: 0,
  listening: 1.1,
  recording: 0.8,
  speaking: 0.55,
};

const OPACITY: Record<WaveformState, number> = {
  idle: 0.3,
  listening: 0.7,
  recording: 1,
  speaking: 1,
};

/* ------------------------------------------------------------------ */
/* VoiceWaveform                                                       */
/* ------------------------------------------------------------------ */

export function VoiceWaveform({
  state = "idle",
  bars = 24,
  className = "",
}: VoiceWaveformProps) {
  const dur = DURATION[state];
  const opacity = OPACITY[state];

  return (
    <>
      <style>{`@keyframes aiui-wave{0%,100%{transform:scaleY(.25)}50%{transform:scaleY(1)}} @media(prefers-reduced-motion:reduce){.scrim-wave-bar{animation:none!important}}`}</style>
      <div className={`flex h-8 items-center gap-[2px] ${className}`} aria-hidden>
        {Array.from({ length: bars }).map((_, i) => {
          /* static bell shape: center bars taller than the edges */
          const t = i / Math.max(bars - 1, 1);
          /* Rounded, not raw: a full-precision float lands in the SSR HTML as
             scaleY(0.6943240406445356), and the browser hands it back to
             hydration as scaleY(0.694324) — a mismatch React reports as an
             error. Three decimals is well below a visible difference. */
          const base = Number((0.3 + 0.7 * Math.sin(Math.PI * t)).toFixed(3));
          return (
            <span
              key={i}
              className="scrim-wave-bar w-[3px] rounded-full bg-current"
              style={{
                height: "100%",
                transform: `scaleY(${base})`,
                transformOrigin: "center",
                animation: dur
                  ? `aiui-wave ${dur}s ease-in-out ${i * (dur / bars)}s infinite`
                  : "none",
                opacity,
              }}
            />
          );
        })}
      </div>
    </>
  );
}

When to use it

  • Animate bars only when audio is actually flowing — a moving idle waveform is noise.
  • Use distinct colors per state (green listening, red recording) so the state reads at a glance.
  • Match the animation speed to real energy: faster bars for speaking, slower for listening.
  • Keep the component silent-friendly — it should also work with a screen reader label.

What breaks in production

  • Fake-animating a waveform while nothing is happening — users learn to distrust it.
  • One ambiguous color for every state; states must be distinguishable without reading.
  • Bars so short they read as static dots, or so tall they crowd the layout.

Related Components