# Listening State

> A pulsing mic indicator showing the AI is actively listening.

- Category: voice
- Job: Ask — Get intent into the AI: composers, voice, attachments and asking in context.
- Slug: `listening-state`
- HTML: https://ai-patterns.app/patterns/voice/listening-state
- JSON: https://ai-patterns.app/api/patterns/voice/listening-state
- Seen in: [Google Gemini](https://support.google.com/gemini/answer/15274899?hl=en&co=GENIE.Platform%3DAndroid)

## Summary
A dedicated visual state — a mic glyph with a halo that swells with the user's voice, a status label with an elapsed timer, 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, amber when no voice is coming through, and muted gray (crossed-out mic) when paused — not a solid-filled badge.
- Voice halo: a soft disc behind the glyph whose size and opacity follow the live mic level, so the user sees their own voice land on the control. Only shown when a real level source is wired up.
- 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", or a silence hint) directly below the glyph, followed by an elapsed timer (`0:07`) while capturing.
- Stop control: a small button beside the label that ends capture — always reachable without needing to find a separate toolbar.

## Behavior
- The halo tracks the real mic level every frame with a fast attack and slow release — it jumps with each syllable and settles between words, never flickers.
- After a few seconds of near-silence on a real level source, swap the label for a plain hint ("Can't hear you — check your mic"), tint the glyph amber and stop the ambient ripple — a ripple that keeps pulsing while nothing is heard is a lie. The moment sound returns, go straight back to "Listening…".
- The elapsed timer restarts each time capture starts, and disappears while paused.
- 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.5]`, opacity `[0, 0.6, 0]`, 1.4s, `easeOut`, infinite repeat, no separate `initial` or per-instance `delay`), with a low-alpha ring color (`/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 hide the expanding ripple and keep the halo from growing — let its opacity alone carry the voice level.
- Hide the ticking timer from screen readers (`aria-hidden`) so it isn't announced every second; the silence hint goes through the same polite live region as the label.
- Never rely on the ripple animation alone to convey "active" — the accent color and label both carry that meaning independently.

## Related patterns
- **Prompt Bar Pro** — Use it instead inside a composer, where its dictation row is the compact version of this state.
- **Voice Waveform** — Use it as well, inside or beside the mic glyph, to show the live amplitude of what's being captured.
- **Live Transcript** — Use it as well to show the recognized text once speech is understood.

## Implementation

Reference implementation (React, TypeScript, Tailwind v4, Motion, Radix). Keep every rule from the spec above; if the host project has its own design system, re-express the markup and motion in it rather than copying these classes.

npm dependencies: `lucide-react`, `motion`

Local imports to provide: `@/lib/utils` (`cn` is the usual `clsx` + `tailwind-merge` helper).

```tsx title="component.tsx"
"use client";

import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { Mic, MicOff, 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;
  /**
   * Live mic loudness (0-1) — e.g. the `level` ref from Voice Waveform's
   * `useMicLevel()`. Read on every frame, so pass a ref to avoid re-renders.
   * When given, the halo swells with the voice and a long silence turns the
   * label into a "can't hear you" hint. When omitted, the glyph just pulses.
   */
  level?: React.RefObject<number> | number;
  label?: string;
  /** Shown instead of `label` after `silenceMs` of near-silence on a real `level`. */
  silenceHint?: string;
  silenceMs?: number;
  /** Show an elapsed-time counter next to the label. */
  showTimer?: boolean;
  onStop?: () => void;
  className?: string;
}

const SILENCE_THRESHOLD = 0.04;

export function ListeningState({
  active = true,
  level,
  label = "Listening…",
  silenceHint = "Can't hear you — check your mic",
  silenceMs = 4000,
  showTimer = true,
  onStop,
  className,
}: ListeningStateProps) {
  const haloRef = React.useRef<HTMLSpanElement>(null);
  const levelRef = React.useRef(level);
  React.useEffect(() => {
    levelRef.current = level;
  });

  const hasLevel = level !== undefined;
  const [silent, setSilent] = React.useState(false);
  const [elapsed, setElapsed] = React.useState(0);

  // Elapsed time restarts every time capture (re)starts.
  React.useEffect(() => {
    if (!active) return;
    const started = Date.now();
    const id = window.setInterval(() => setElapsed(Math.floor((Date.now() - started) / 1000)), 250);
    return () => {
      window.clearInterval(id);
      setElapsed(0);
    };
  }, [active]);

  // Halo follows the voice: fast attack, slow release, written straight to the DOM each frame.
  React.useEffect(() => {
    if (!active || !hasLevel) return;
    const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    let smoothed = 0;
    let quietSince = performance.now();
    let isSilent = false;
    let frame = 0;

    const tick = (now: number) => {
      const raw = readLevel(levelRef.current);
      smoothed += (raw - smoothed) * (raw > smoothed ? 0.4 : 0.1);

      if (raw > SILENCE_THRESHOLD) quietSince = now;
      const nextSilent = now - quietSince > silenceMs;
      if (nextSilent !== isSilent) {
        isSilent = nextSilent;
        setSilent(nextSilent);
      }

      const halo = haloRef.current;
      if (halo) {
        halo.style.transform = `scale(${reducedMotion ? 1 : 1 + smoothed * 0.7})`;
        halo.style.opacity = String(0.15 + smoothed * 0.5);
      }
      frame = requestAnimationFrame(tick);
    };
    frame = requestAnimationFrame(tick);
    return () => {
      cancelAnimationFrame(frame);
      setSilent(false);
    };
  }, [active, hasLevel, silenceMs]);

  const status = !active ? "Paused" : silent ? silenceHint : label;

  return (
    <div className={cn("flex flex-col items-center gap-3", className)}>
      <div
        className={cn(
          "relative flex size-14 shrink-0 items-center justify-center rounded-full transition-colors duration-300",
          !active ? "text-muted-foreground" : silent ? "text-amber-500" : "text-sky-500"
        )}
      >
        {/* Voice-driven halo: swells with each syllable. */}
        {active && hasLevel && (
          <span
            ref={haloRef}
            aria-hidden
            className="absolute inset-0 rounded-full bg-current opacity-15 will-change-transform"
          />
        )}
        {/* Ambient ripple: "the mic is live" even between words. */}
        {active && !silent && (
          <motion.span
            key="ripple"
            aria-hidden
            className="absolute inset-0 rounded-full bg-current/20 motion-reduce:hidden"
            animate={{ scale: [1, 1.5], opacity: [0, 0.6, 0] }}
            transition={{ duration: 1.4, repeat: Infinity, ease: "easeOut" }}
          />
        )}
        <AnimatePresence mode="wait" initial={false}>
          <motion.span
            key={active ? "on" : "off"}
            initial={{ scale: 0.6, opacity: 0 }}
            animate={{ scale: 1, opacity: 1 }}
            exit={{ scale: 0.6, opacity: 0 }}
            transition={{ duration: 0.15 }}
            className="relative"
          >
            {active ? <Mic className="size-6" aria-hidden /> : <MicOff className="size-6" aria-hidden />}
          </motion.span>
        </AnimatePresence>
      </div>

      <div className="flex min-h-6 items-center gap-2">
        <span aria-live="polite" className="text-sm text-muted-foreground">
          <AnimatePresence mode="wait" initial={false}>
            <motion.span
              key={status}
              initial={{ opacity: 0, y: 3 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: -3 }}
              transition={{ duration: 0.15 }}
              className="inline-block"
            >
              {status}
            </motion.span>
          </AnimatePresence>
        </span>
        {showTimer && active && (
          <span className="text-sm tabular-nums text-muted-foreground/70" aria-hidden>
            {formatElapsed(elapsed)}
          </span>
        )}
        {onStop && active && (
          <button
            type="button"
            onClick={onStop}
            aria-label="Stop listening"
            className="flex size-6 items-center justify-center rounded-full bg-muted text-foreground transition-colors hover:bg-accent"
          >
            <Square className="size-2.5" aria-hidden fill="currentColor" />
          </button>
        )}
      </div>
    </div>
  );
}

function formatElapsed(seconds: number) {
  return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
}

function readLevel(level: ListeningStateProps["level"]) {
  if (typeof level === "number") return Math.min(1, Math.max(0, level));
  if (level && typeof level.current === "number") return Math.min(1, Math.max(0, level.current));
  return 0;
}
```
