AI Patterns

voice

Listening State

A pulsing mic indicator showing the AI is actively listening.

Listening…
"use client";

import * as React from "react";
import { motion } from "motion/react";
import { Mic, Square } from "lucide-react";

import { cn } from "@/lib/utils";

export interface ListeningStateProps {
  /** Whether the mic is actively capturing audio. Set false for a paused/muted variant with no ripple. */
  active?: boolean;
  label?: string;
  onStop?: () => void;
  className?: string;
}

export function ListeningState({
  active = true,
  label = "Listening…",
  onStop,
  className,
}: ListeningStateProps) {
  return (
    <div className={cn("flex flex-col items-center gap-3", className)}>
      <div
        className={cn(
          "relative flex size-12 shrink-0 items-center justify-center rounded-full transition-colors",
          active ? "text-sky-500" : "text-muted-foreground"
        )}
      >
        {active && (
          <motion.span
            aria-hidden
            className="absolute inset-0 rounded-full bg-sky-500/20"
            animate={{ scale: [1, 1.4], opacity: [0, 0.6, 0] }}
            transition={{ duration: 1.2, repeat: Infinity, ease: "easeOut" }}
          />
        )}
        <Mic className="size-6" aria-hidden />
      </div>

      <div className="flex items-center gap-2">
        <span aria-live="polite" className="text-sm text-muted-foreground">
          {active ? label : "Paused"}
        </span>
        {onStop && (
          <button
            type="button"
            onClick={onStop}
            aria-label="Stop listening"
            className="rounded-full p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
          >
            <Square className="size-3" aria-hidden fill="currentColor" />
          </button>
        )}
      </div>
    </div>
  );
}

A UX spec for this pattern — written for agents implementing or reusing it, not the code.

# Listening State

## Summary
A dedicated visual state — a pulsing mic glyph with an outward ripple, a status label, and a stop control — that tells the user the system is actively capturing their voice right now. It's the "your mic is live" moment in a voice interface, distinct from showing what was heard (Live Transcript) or how loud it is (Voice Waveform).

## When to use
- The instant a voice interface starts capturing audio, whether triggered by a push-to-talk press, a wake word, or an always-on mode — show this before or alongside any transcript text appears.
- As the anchor state users glance at to confirm "yes, it's hearing me" separate from reading a waveform or transcript, especially for brief utterances where a transcript hasn't rendered yet.
- Any time capture is paused (muted, held) but the session is still open — use the inactive variant rather than removing the component entirely, so the control to resume stays in place.

## When not to use
- While the assistant is talking, not the user — this state specifically means "capturing your voice." Use a distinct speaking indicator (e.g. Voice Waveform in its `speaking` state) for that half of the turn.
- As a permanent idle-mode decoration when no capture session is open — it implies the mic is live; don't show it before the user has actually started or been prompted to speak.
- Stacked with a second, separate ripple/pulse indicator for the same mic state — one active listening indicator per view.

## Anatomy
- Mic glyph: a plain (unfilled) microphone icon, tinted with an accent color while active and muted gray when paused — not a solid-filled badge. This mirrors Prompt Bar's dictation mic exactly, so the two read as the same control at any size.
- Ripple: a single translucent ring, sized and centered on the glyph itself (not a separate oversized container), that expands outward a short distance and fades to nothing, looping continuously. Because the glyph has no opaque fill behind it, the full ring is visible from the start of each cycle, not just the portion that clears a solid background.
- Status label: a short live-updating phrase ("Listening…", "Paused") directly below the glyph.
- Stop control: a small button beside the label that ends capture — always reachable without needing to find a separate toolbar.

## Behavior
- The ripple only animates while `active` is true; pausing or muting freezes the glyph in a flat, static state with the ripple removed rather than slowed down, so "paused" is unambiguous at a glance.
- Clicking stop ends the capture session immediately — no confirmation step, since capture is easy to restart and holding it hostage behind a dialog adds friction to a moment that's meant to feel instant.
- The label updates in place (no layout shift) when switching between active and paused text.
- On resume, restart the ripple animation from its initial state rather than resuming mid-cycle, so the "just started listening" cue is clear each time.
- Drive the ripple with a plain keyframe `animate` (scale `[1, 1.4]`, opacity `[0, 0.6, 0]`, 1.2s, `easeOut`, infinite repeat, no separate `initial` or per-instance `delay`) — this is copied verbatim from Prompt Bar's dictation mic, down to the ring's low-alpha color treatment (`/20`). Staggering multiple rings via `delay` on an infinitely-repeating animation is fragile and prone to drifting out of sync; one ring on a clean loop reads just as clearly as "listening."
- Fade the ripple's opacity in from 0 and back out to 0 within each cycle rather than starting it at full strength — a ring that starts fully visible and simply expands has to hard-reset to a small, bold ring the instant it disappears, which reads as the ring suddenly shrinking rather than continuing to expand. Fading through 0 at both ends of the loop makes the reset invisible.

## Content guidelines
- Keep the label to a short present-participle phrase ("Listening…"); avoid restating instructions the user already knows ("Speak now to ask a question").
- The paused label states the state plainly ("Paused"), not an instruction to act ("Tap mic to continue") — pair any needed instruction with a visible affordance instead of relying on the label alone.

## Accessibility
- Wrap the label in `aria-live="polite"` so a screen reader announces the active/paused transition without needing focus.
- The stop control must be a real, focusable `<button>` with an `aria-label` ("Stop listening") since it carries no visible text.
- Respect `prefers-reduced-motion`: keep the glyph's color state (it's the actual status signal) but suppress or shorten the expanding ripple.
- Never rely on the ripple animation alone to convey "active" — the accent color and label both carry that meaning independently.

## Related patterns
- Prompt Bar / Prompt Bar Pro — their composer's dictation mic button uses this same single-ring ripple technique at a smaller scale; keep both in sync if the ripple's timing or easing ever changes.
- Voice Waveform — shows the live amplitude of the audio this state confirms is being captured; often shown together, with the waveform inside or beside the mic glyph.
- Live Transcript — shows what capture actually produced once speech is recognized; this state covers the moment before or alongside that text appearing.