Swish UI
All componentsFeedback and forms Free

Voice bars

A mic button whose bars breathe at rest, dance while listening, then fold into a line and a drawn check.

It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.

Demo.tsx

"use client";
import { useEffect, useRef, useState, type CSSProperties } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";

const BARS = 28;
const AUTO_LENGTH = 7000; // an auto take stops itself at 0:07
const IDLE_WAIT = 2200;
const DONE_WAIT = 3600;
const NOTE = "Move the design review to Thursday at 3pm and invite Priya.";
type Phase = "idle" | "listening" | "done";

const clock = (ms: number) => {
  const s = Math.floor(ms / 1000);
  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
};
// A still waveform for reduced motion and for the first paint of a take.
const still = (i: number) => 0.25 + 0.6 * Math.abs(Math.sin(i * 0.9) * Math.cos(i * 0.37));

export default function Demo() {
  const root = useRef<HTMLDivElement>(null);
  const bars = useRef<(HTMLSpanElement | null)[]>([]);
  const timer = useRef<HTMLSpanElement>(null);
  const elapsed = useRef(0);
  const auto = useRef(true);
  const inView = useInView(root);
  const reduced = usePrefersReducedMotion();
  const [phase, setPhase] = useState<Phase>("idle");
  const [took, setTook] = useState(7000);

  const stop = () => {
    setTook(Math.max(1000, elapsed.current));
    setPhase("done");
  };
  const press = (byUser: boolean) => {
    auto.current = !byUser;
    if (phase === "listening") stop();
    else {
      elapsed.current = 0;
      if (timer.current) timer.current.textContent = clock(0);
      setPhase("listening");
    }
  };

  // Reduced motion starts on the finished take and never plays the loop.
  useEffect(() => {
    if (reduced) setPhase("done");
  }, [reduced]);

  // Idle and done hand over to the next phase on a timer, only while on screen and not in reduced motion.
  useEffect(() => {
    if (reduced || !inView || phase === "listening") return;
    const t = setTimeout(() => (phase === "idle" ? press(false) : setPhase("idle")), phase === "idle" ? IDLE_WAIT : DONE_WAIT);
    return () => clearTimeout(t);
  }, [phase, inView, reduced]);

  // Listening: every bar follows a smooth pseudo random level, written straight to the DOM.
  useEffect(() => {
    const els = bars.current.slice();
    const clockEl = timer.current;
    if (phase !== "listening") {
      els.forEach((el) => el && (el.style.transform = ""));
      return;
    }
    if (reduced || !inView) {
      els.forEach((el, i) => el && (el.style.transform = `scaleY(${still(i)})`));
      // The timer still counts in reduced motion, it is text, not movement.
      if (!reduced) return;
      let last = performance.now();
      const id = setInterval(() => {
        const now = performance.now();
        elapsed.current += now - last;
        last = now;
        if (clockEl) clockEl.textContent = clock(elapsed.current);
      }, 250);
      return () => clearInterval(id);
    }
    const levels = els.map(() => 0.15);
    let raf = 0, last = performance.now();
    const frame = (now: number) => {
      const dt = Math.min(64, now - last);
      last = now;
      elapsed.current += dt;
      const t = elapsed.current / 1000;
      // Syllable envelope: loud bursts with short gaps, like speech.
      const voice = 0.3 + 0.7 * Math.max(0, Math.sin(t * 5.3 + 1.3 + Math.sin(t * 1.3) * 2.2)) * (0.6 + 0.4 * Math.sin(t * 0.8 + 1));
      els.forEach((el, i) => {
        if (!el) return;
        const x = i / (BARS - 1) - 0.5;
        const shape = 1 - x * x * 2.2; // louder in the middle
        const grain = 0.5 + 0.5 * Math.sin(t * (7 + (i % 5)) + i * 1.7) * Math.cos(t * 3.1 - i * 0.6);
        const target = 0.12 + 0.88 * shape * voice * (0.45 + 0.55 * grain);
        levels[i] += (target - levels[i]) * Math.min(1, dt / 70);
        el.style.transform = `scaleY(${Math.max(0.08, levels[i]).toFixed(3)})`;
      });
      if (clockEl) clockEl.textContent = clock(elapsed.current);
      if (auto.current && elapsed.current >= AUTO_LENGTH) {
        stop();
        return;
      }
      raf = requestAnimationFrame(frame);
    };
    raf = requestAnimationFrame(frame);
    return () => cancelAnimationFrame(raf);
  }, [phase, inView, reduced]);

  const status = phase === "listening" ? "Listening" : phase === "done" ? `Transcribed ${clock(took)}` : "Tap to dictate";

  return (
    <div ref={root} className={styles.root}>
      <section className={styles.card} data-phase={phase} aria-label="Voice note">
        <header className={styles.head}>
          <p className={styles.status} aria-live="polite">
            <span className={styles.dot} aria-hidden="true" />
            <span key={status} className={styles.label}>{status}</span>
          </p>
          <span ref={timer} className={styles.timer} aria-hidden={phase !== "listening"}>
            {phase === "done" ? clock(took) : "0:00"}
          </span>
        </header>
        <div className={styles.row}>
          <button
            type="button"
            className={styles.mic}
            onClick={() => press(true)}
            aria-label={phase === "listening" ? "Stop dictation" : "Start dictation"}
          >
            <svg viewBox="0 0 20 20" aria-hidden="true">
              {phase === "listening" ? (
                <rect x="6" y="6" width="8" height="8" rx="1.5" className={styles.fillIcon} />
              ) : (
                <>
                  <rect x="7.25" y="2.5" width="5.5" height="10" rx="2.75" />
                  <path d="M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" />
                </>
              )}
            </svg>
          </button>
          <div className={styles.wave} aria-hidden="true">
            <div className={styles.bars}>
              {Array.from({ length: BARS }, (_, i) => (
                <span
                  key={i}
                  ref={(el) => { bars.current[i] = el; }}
                  className={styles.bar}
                  style={{ "--i": i, "--h": still(i).toFixed(2) } as CSSProperties}
                />
              ))}
            </div>
            <svg className={styles.check} viewBox="0 0 24 24">
              <path d="M5 12.5l4.5 4.5L19 7.5" />
            </svg>
          </div>
        </div>
        <p className={styles.note}>
          {phase === "done" ? <span className={styles.text}>{NOTE}</span> : <span className={styles.hint}>Speak naturally, we will tidy it up.</span>}
        </p>
      </section>
    </div>
  );
}

demo.module.css

.root {
  container-type: size; display: grid; place-items: center; width: 100%; height: 100%; padding: 16px;
  box-sizing: border-box; background: var(--sky);
}
.card {
  width: min(420px, 100%); display: grid; gap: 14px; padding: 16px 18px; box-sizing: border-box;
  border-radius: 18px; border: 1px solid var(--line); background: var(--white);
  box-shadow: 0 18px 40px -28px color-mix(in srgb, var(--ink) 45%, transparent);
}
.head { display: flex; align-items: center; gap: 10px; }
.status { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 13px; font-weight: 650; min-width: 0; }
.label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; animation: label-in .35s var(--out); }
@keyframes label-in { from { opacity: 0; translate: 0 5px; } }
.dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--line); transition: background-color .3s; }
.card[data-phase="listening"] .dot { background: var(--accent); animation: blink 1.1s var(--out) infinite; }
.card[data-phase="done"] .dot { background: var(--accent-text); }
@keyframes blink { 50% { opacity: .3; } }
.timer {
  margin-left: auto; font-size: 13px; font-weight: 650; color: var(--muted); font-variant-numeric: tabular-nums;
  transition: color .3s;
}
.card[data-phase="listening"] .timer { color: var(--ink); }
.card[data-phase="done"] .timer { opacity: 0; }

.row { display: flex; align-items: center; gap: 14px; }

/* The mic: a ring breathes out of it while listening. */
.mic {
  position: relative; flex: none; display: grid; place-items: center; width: 52px; height: 52px; padding: 0;
  border-radius: 50%; border: 0; background: var(--ink); color: var(--white); cursor: pointer;
  transition: transform .2s var(--out), background-color .3s;
}
.mic::after {
  content: ""; position: absolute; inset: -2px; border-radius: 50%; border: 2px solid var(--accent);
  opacity: 0; pointer-events: none;
}
.card[data-phase="listening"] .mic { background: var(--accent-text); }
.card[data-phase="listening"] .mic::after { animation: ring 1.6s var(--out) infinite; }
@keyframes ring { from { opacity: .8; scale: 1; } to { opacity: 0; scale: 1.45; } }
.mic:active { transform: scale(.93); }
.mic:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 3px; }
.mic svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; }
.mic .fillIcon { fill: currentColor; stroke: none; }

/* Bars: idle breathes in CSS, listening is driven from JS, done collapses into a line and hands over to the check. */
.wave { position: relative; flex: 1; min-width: 0; height: 52px; }
.bars {
  position: absolute; inset: 0; display: flex; align-items: center; justify-content: space-between; gap: 3px;
  transition: opacity .3s .35s, scale .35s var(--out) .35s;
}
.bar {
  flex: 1; max-width: 5px; height: 100%; border-radius: 3px; background: var(--ink); opacity: .32;
  transform: scaleY(.18); animation: breathe 2.4s var(--out) infinite; animation-delay: calc(var(--i) * -110ms);
  transition: transform .35s var(--out), background-color .3s, opacity .3s;
}
@keyframes breathe { 50% { transform: scaleY(calc(var(--h) * .6)); } }
.card[data-phase="listening"] .bar { animation: none; transition: background-color .3s, opacity .3s; background: var(--accent-text); opacity: 1; }
.card[data-phase="done"] .bar { animation: none; transform: scaleY(.06); background: var(--accent-text); opacity: 1; }
.card[data-phase="done"] .bars { opacity: 0; scale: .2 1; }

.check {
  position: absolute; left: 50%; top: 50%; width: 30px; height: 30px; margin: -15px 0 0 -15px; fill: none;
  stroke: var(--accent-text); stroke-width: 2.6; stroke-linecap: round; stroke-linejoin: round;
  stroke-dasharray: 24; stroke-dashoffset: 24; opacity: 0;
}
.card[data-phase="done"] .check { opacity: 1; animation: draw .45s var(--out) .55s both; }
@keyframes draw { from { stroke-dashoffset: 24; } to { stroke-dashoffset: 0; } }

.note { margin: 0; min-height: 2.7em; font-size: 13px; line-height: 1.35; }
.hint { color: var(--muted); }
.text { display: block; animation: label-in .5s var(--out) .7s both; }

@container (min-width: 640px) and (min-height: 400px) {
  .card { width: 480px; padding: 20px 22px; gap: 16px; }
  .mic { width: 60px; height: 60px; }
  .wave { height: 60px; }
  .status, .timer, .note { font-size: 14px; }
}
@container (max-width: 340px) {
  .card { padding: 14px; gap: 12px; }
  .row { gap: 10px; }
  .mic { width: 44px; height: 44px; }
  .wave { height: 44px; }
  .bar:nth-child(odd) { display: none; }
}
@container (max-height: 250px) {
  .card { gap: 10px; padding-block: 12px; }
}
@media (prefers-reduced-motion: reduce) {
  .label, .text { animation: none; }
  .bar { animation: none; transform: scaleY(var(--h)); transition: none; }
  .card[data-phase="listening"] .dot, .card[data-phase="listening"] .mic::after { animation: none; }
  .bars { transition: none; }
  .card[data-phase="done"] .check { animation: none; stroke-dashoffset: 0; }
}

Use it for

  • Dictation, voice notes and meeting capture
  • Hero demos for AI note takers and assistants
  • Any record control that needs a clear listening state

Avoid

  • Real audio meters, where levels should come from an AnalyserNode
  • Dense toolbars, where a moving waveform pulls focus

How it works

One phase (idle, listening, done) sets a data attribute on the card. Idle bars breathe with a staggered CSS keyframe; while listening a requestAnimationFrame loop eases each bar toward a level built from a syllable envelope times per bar sine noise and writes scaleY and the timer through refs. On stop the inline transforms are cleared so CSS transitions the bars to a flat line, squeezes the row away and draws the check with stroke-dashoffset.

Accessibility

The mic is a real button whose label switches between Start and Stop dictation, and the status line is a polite live region. The loop pauses off screen. Reduced motion shows a still waveform and the finished transcript, and the button still records with a counting timer.

Found on