Voice Waveform Listen
Bars rise and fall against the room while the microphone is open, so the mic reads as live.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Voice Waveform Listen
*
* The bars an assistant shows while the microphone is open: an
* irregular, continuously moving level meter that says the room is being
* heard, without promising a duration.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so it reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `bars`, `status`, `color`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type VoiceWaveformListenProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** How many bars make up the meter. */
bars?: number;
/** Text beside the meter. */
status?: string;
/** Full bar height in px. */
height?: number;
/** Bar and microphone color. */
color?: string;
};
type VariantConfig = {
/** Seconds for one full excursion of a bar. */
cycleSeconds: number;
/** Level a quiet bar rests at, 0–1. */
floor: number;
/** Level the loudest bar reaches, 0–1. */
ceiling: number;
/** Seconds of offset between neighbouring bars. */
offset: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Shallow and slow — a meter that can sit in a toolbar all day.
subtle: { cycleSeconds: 1.1, floor: 0.28, ceiling: 0.68, offset: 0.05 },
// The all-purpose setting: clearly alive, still calm.
default: { cycleSeconds: 0.85, floor: 0.18, ceiling: 0.92, offset: 0.06 },
// Fast and full-range, for a full-screen voice moment.
playful: { cycleSeconds: 0.62, floor: 0.12, ceiling: 1, offset: 0.07 },
};
/**
* Deterministic per-bar levels. Real audio is not a sine wave, but it is
* also not random noise, so two out-of-phase sines give neighbouring
* bars different amplitudes without letting them march in lockstep — and
* being deterministic, the server and the client render the same meter.
*/
function levelsFor(index: number, count: number, cfg: VariantConfig) {
const middle = (count - 1) / 2;
// Loudest in the middle, tapering to the ends, like a real meter.
const envelope = 1 - Math.abs(index - middle) / (middle + 1.4);
const jitter = (Math.sin(index * 12.9898) + 1) / 2;
const peak = cfg.floor + (cfg.ceiling - cfg.floor) * envelope * (0.55 + jitter * 0.45);
const dip = cfg.floor + (peak - cfg.floor) * 0.22;
const mid = cfg.floor + (peak - cfg.floor) * 0.66;
return { peak, dip, mid };
}
/** Theme-adaptive neutral: `currentColor` is the text color this
* component inherits — near-black on a light page, near-white on a dark
* one — so mixing it with `transparent` yields a surface, border or fill
* that is correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function VoiceWaveformListen({
variant = "default",
bars = 15,
status = "Listening",
height = 30,
color = "#7C7CF0",
}: VoiceWaveformListenProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
return (
<div
role="status"
aria-label={status}
style={{
display: "inline-flex",
alignItems: "center",
gap: 13,
padding: "10px 16px 10px 10px",
borderRadius: 999,
background: tone(6),
border: `1px solid ${tone(11)}`,
}}
>
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
width: 30,
height: 30,
borderRadius: "50%",
background: color,
color: "#fff",
flex: "0 0 auto",
}}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<rect
x="5"
y="1.4"
width="4"
height="7"
rx="2"
stroke="currentColor"
strokeWidth="1.4"
/>
<path
d="M2.8 6.6a4.2 4.2 0 0 0 8.4 0M7 10.8v1.8"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
/>
</svg>
</span>
<span
aria-hidden
style={{
display: "flex",
alignItems: "center",
gap: 3,
height,
}}
>
{Array.from({ length: bars }, (_, index) => {
const { peak, dip, mid } = levelsFor(index, bars, cfg);
return (
<motion.span
key={index}
// Reduced motion: the meter freezes at its envelope. The
// shape still reads as a level meter and the status label
// still says the microphone is open — only the movement,
// which is the part that can be distracting, is dropped.
animate={
reduceMotion
? { scaleY: mid }
: { scaleY: [dip, peak, mid, dip] }
}
transition={
reduceMotion
? { duration: 0 }
: {
duration: cfg.cycleSeconds,
repeat: Infinity,
repeatType: "mirror",
ease: "easeInOut",
delay: index * cfg.offset,
}
}
style={{
width: 3,
height,
borderRadius: 999,
background: color,
opacity: 0.9,
transformOrigin: "center",
}}
/>
);
})}
</span>
<span style={{ fontSize: 13, fontWeight: 550, opacity: 0.62 }}>
{status}
</span>
</div>
);
}About this pattern
An open microphone with no feedback is indistinguishable from a broken one. The meter answers that by moving continuously and irregularly: each bar runs its own cycle, offset from its neighbours, with amplitudes tapering toward the ends the way a real level meter does. The levels come from two out-of-phase sines rather than random noise, so the shape never marches in lockstep and the server renders exactly what the client does. Bars are scaled, not resized, so the whole meter is one composited transform.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- AI assistant
A live meter shown for the duration that the microphone is capturing.
Related patterns
- Scan SweepA scan line travels across the surface being analyzed, then rests before the next pass.
- Transcription Word LockEach spoken word lands dimmed under a dotted rule, then firms up to solid once the recognizer commits.
- Embedding Cluster SettleScattered points drift into labelled groups, the halos and captions landing after them.