# Waveform Activity Bars

Waveform Activity Bars [#waveform-activity-bars]

**Waveform Activity Bars** render a row of narrow vertical bars with a staggered, sinusoid-derived pulse. It needs no audio data — use it as an "active processing" cue, an idle empty-state illustration, or (with an explicit `state` + a `volume` callback) a voice-agent visualizer that reacts to live microphone or output loudness.

The required `@keyframes` ships inside the component (injected once into `document.head`), so it animates standalone after `shadcn add`.

**When to use it:** show that STT/TTS is working (`useTranscribe` / `useStreamSpeech` / `useVoiceRecorder` activity), fill an empty audio surface, or visualize agent states (connecting / listening / thinking / speaking).

Preview [#preview]

```tsx
'use client';

import { useState } from 'react';
import { WaveformActivityBars, type WaveformState } from '@/components/waveform-activity-bars';

/**
 * Demo for {@link WaveformActivityBars}. Shows the active/idle indicator plus
 * the five agent-state modes side by side, each clearly labeled and visibly
 * distinct (color, amplitude, speed, bar count, and animation pattern all vary
 * per state). A slider drives a simulated `volume` so you can see the
 * amplitude-decoupled rendering without wiring a real mic.
 */
const STATES: { state: WaveformState; hint: string }[] = [
  { state: 'idle', hint: 'flat · still · muted' },
  { state: 'connecting', hint: 'low blips · muted' },
  { state: 'listening', hint: 'lively · primary' },
  { state: 'thinking', hint: 'travelling · amber' },
  { state: 'speaking', hint: 'tall · fast · emerald' },
];

export default function WaveformActivityBarsDemo() {
  const [volume, setVolume] = useState(0.5);

  return (
    <div className="flex flex-col gap-6">
      <div className="flex items-center gap-6">
        <div className="flex flex-col items-center gap-1">
          <WaveformActivityBars active height={28} />
          <span className="text-xs text-muted-foreground">active</span>
        </div>
        <div className="flex flex-col items-center gap-1">
          <WaveformActivityBars active={false} height={28} />
          <span className="text-xs text-muted-foreground">idle</span>
        </div>
      </div>

      <div className="grid grid-cols-2 gap-4 sm:grid-cols-5">
        {STATES.map(({ state, hint }) => (
          <div
            key={state}
            className="flex flex-col items-center gap-1 rounded-md border border-border p-3"
          >
            <div className="flex h-8 items-end">
              <WaveformActivityBars state={state} height={28} />
            </div>
            <span className="text-xs font-medium text-foreground">{state}</span>
            <span className="text-[10px] text-muted-foreground">{hint}</span>
          </div>
        ))}
      </div>

      <div className="flex items-center gap-3">
        <WaveformActivityBars
          state="listening"
          volume={volume}
          height={32}
          barCount={9}
        />
        <input
          type="range"
          min={0}
          max={1}
          step={0.01}
          value={volume}
          onChange={(e) => setVolume(Number(e.target.value))}
          className="w-40"
          aria-label="simulated input volume"
        />
        <span className="text-xs tabular-nums text-muted-foreground">
          volume {volume.toFixed(2)}
        </span>
      </div>
    </div>
  );
}
```

Installation [#installation]

```bash
npx shadcn@latest add @localmode/ui/audio/waveform-activity-bars
```

Dependencies [#dependencies]

* `clsx` + `tailwind-merge` — via the shared `cn()` util (installed automatically as a registry dependency)
* No external audio dependency — it is pure CSS

Files installed [#files-installed]

* `waveform-activity-bars.tsx` — the component (bundles its keyframe)
* `lib/utils.ts` — the `cn()` helper (if not already present)

Props [#props]

**WaveformActivityBars**

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `barCount` | `number` | `5` | Number of vertical bars to render. When a `state` is set and this is omitted, each state picks a distinct count (e.g. fewer for `idle`, more for `speaking`); an explicit value always wins. |
| `active` | `boolean` | `true` | When true, bars animate with a staggered pulse; when false they render as a static idle illustration. Ignored when an explicit `state` is set. |
| `state` | `"playback" \| "record" \| "speaking" \| "thinking" \| "listening" \| "connecting" \| "idle"` | — | Explicit agent / processing state. Takes precedence over `active` and lets the same component double as a voice-agent visualizer. |
| `color` | `string` | `"var(--primary)"` | Bar color. Any CSS color. When a `state` is set and this is omitted, each state uses a semantic color (muted for `idle`/`connecting`, primary for `listening`, amber for `thinking`, emerald for `speaking`); an explicit value always wins. Defaults to the theme's primary token when no `state`. |
| `height` | `number` | `24` | Max bar height in pixels (the row height). |
| `volume` | `number` | — | Optional live volume in the `[0, 1]` range. When provided the bars scale to the measured amplitude, decoupling the visual from any specific audio source — feed it from a Web Audio `AnalyserNode` (`getInputVolume()` / `getOutputVolume()`). |
| `label` | `string` | `"audio activity"` | Accessible label for the row. |

Examples [#examples]

Active processing indicator [#active-processing-indicator]

```tsx
import { WaveformActivityBars } from '@/components/waveform-activity-bars';

export function Example() {
  return <WaveformActivityBars active />;
}
```

Driven by a transcription hook [#driven-by-a-transcription-hook]

```tsx
import { WaveformActivityBars } from '@/components/waveform-activity-bars';
import { useTranscribe } from '@localmode/react';

export function TranscribeIndicator({ model }) {
  const { isLoading } = useTranscribe({ model });
  return <WaveformActivityBars active={isLoading} />;
}
```

Voice-agent state + live volume [#voice-agent-state--live-volume]

```tsx
// Feed `volume` (0..1) from useVoiceRecorder().getVolume() — sample it in a
// requestAnimationFrame loop while recording (or use any AnalyserNode source).
<WaveformActivityBars state="listening" volume={recorder.getVolume()} />
```

Customization [#customization]

The bars are positioned with Tailwind utilities and colored via the `color` prop (defaults to `var(--primary)`), so they inherit your theme. The pulse keyframe (`lm-waveform-pulse`) is injected once; if you prefer it in your global stylesheet, move the `KEYFRAME_CSS` block into `globals.css` and delete the injection effect from the copied file.

When a `volume` is supplied the component switches from time-based animation to amplitude-based scaling — the bars track measured loudness instead of wall-clock time, keeping the visual decoupled from the audio source.