Voice Input Bar with Live Waveform

A bright voice input for an AI assistant. A pill shaped bar holds a pulsing mic button, a 48 bar waveform, a recording clock and a cancel button. Above it the transcript arrives word by word, with the newest words tinted until they settle, and the waveform swells with each word. When the note ends the bars fold into a travelling ripple while the assistant works, then two result cards spring in. Everything is simulated, so no microphone permission is requested. On phones every second bar is hidden to keep the bar comfortable.

Code

import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "framer-motion";
import { Bell, CalendarCheck, Check, Mic, PackageCheck, Square, Truck, X } from "lucide-react";

// Simulated dictation. No microphone is used: the words and the waveform are scripted.
const phrases = [
  {
    text: "Remind me to send the Harbor Street quote to Imani before Friday, and book the van for Monday morning.",
    results: [
      { icon: Bell, title: "Reminder set", detail: "Send Harbor Street quote to Imani, Thu 4:00 pm" },
      { icon: CalendarCheck, title: "Van booked", detail: "Monday 8:00 am, pickup from the yard" },
    ],
  },
  {
    text: "Add two bags of grout and a tile cutter blade to the order for the Okafor bathroom job.",
    results: [
      { icon: PackageCheck, title: "Order updated", detail: "2 items added to the Okafor bathroom job" },
      { icon: Truck, title: "Supplier notified", detail: "Delivery moved to Wednesday morning" },
    ],
  },
];

const BARS = 48;
type Phase = "idle" | "listening" | "processing" | "done";
const restShape = (i: number) => 0.2 + 0.75 * Math.pow(Math.sin((Math.PI * (i + 0.5)) / BARS), 2) * (0.55 + 0.45 * Math.abs(Math.sin(i * 1.9)));

export default function VoiceInputBarWithLiveWaveform() {
  const reduce = useReducedMotion();
  const rootRef = useRef<HTMLElement>(null);
  const barRefs = useRef<(HTMLSpanElement | null)[]>([]);
  const phaseRef = useRef<Phase>("listening");
  const energy = useRef(0.6);
  const inView = useInView(rootRef, { amount: 0.3 });
  const [phase, setPhase] = useState<Phase>("listening");
  const [phraseIndex, setPhraseIndex] = useState(0);
  const [count, setCount] = useState(0);
  const [seconds, setSeconds] = useState(0);
  const [demo, setDemo] = useState(true);

  const phrase = phrases[phraseIndex];
  const words = phrase.text.split(" ");
  phaseRef.current = phase;

  // The script: hear a word, hear the next, then think, then show what was done.
  useEffect(() => {
    if (!inView) return;
    if (reduce) {
      if (phase === "listening") (setCount(words.length), setPhase("done"));
      return;
    }
    let timer: ReturnType<typeof setTimeout> | undefined;
    if (phase === "listening") {
      if (count < words.length) {
        timer = setTimeout(
          () => {
            energy.current = 1;
            setCount(count + 1);
          },
          count === 0 ? 260 : 150 + words[count - 1].length * 32,
        );
      } else timer = setTimeout(() => setPhase("processing"), 700);
    } else if (phase === "processing") {
      timer = setTimeout(() => setPhase("done"), 1300);
    } else if (phase === "done" && demo) {
      timer = setTimeout(() => {
        setPhraseIndex((i) => (i + 1) % phrases.length);
        setCount(0);
        setSeconds(0);
        setPhase("listening");
      }, 3200);
    }
    return () => clearTimeout(timer);
  }, [phase, count, inView, reduce, demo, words.length, phraseIndex]);

  // Recording clock.
  useEffect(() => {
    if (phase !== "listening" || !inView || reduce) return;
    const timer = setInterval(() => setSeconds((s) => s + 1), 1000);
    return () => clearInterval(timer);
  }, [phase, inView, reduce]);

  // Waveform: one animation frame loop that writes scaleY straight to the bars, with no React renders.
  useEffect(() => {
    if (reduce || !inView) return;
    let frame = 0;
    const levels = new Array(BARS).fill(0.1);
    const tick = (now: number) => {
      const t = now / 1000;
      const mode = phaseRef.current;
      energy.current *= 0.972;
      for (let i = 0; i < BARS; i++) {
        const bell = Math.pow(Math.sin((Math.PI * (i + 0.5)) / BARS), 1.1);
        let target = 0.07;
        if (mode === "listening") {
          const ripple = 0.5 + 0.5 * Math.sin(t * 9 + i * 0.9) * Math.sin(t * 4.3 + i * 1.7);
          target = 0.1 + (0.35 + 0.65 * energy.current) * bell * (0.35 + 0.65 * ripple);
        } else if (mode === "processing") {
          target = 0.08 + 0.42 * Math.pow(Math.max(0, Math.sin(t * 5 - i * 0.32)), 6);
        }
        levels[i] += (target - levels[i]) * 0.3;
        const bar = barRefs.current[i];
        if (bar) bar.style.transform = `scaleY(${levels[i].toFixed(3)})`;
      }
      frame = requestAnimationFrame(tick);
    };
    const start = () => {
      cancelAnimationFrame(frame);
      if (!document.hidden) frame = requestAnimationFrame(tick);
    };
    start();
    document.addEventListener("visibilitychange", start);
    return () => {
      cancelAnimationFrame(frame);
      document.removeEventListener("visibilitychange", start);
    };
  }, [reduce, inView]);

  const toggle = () => {
    setDemo(false);
    if (phase === "listening") return setPhase("processing");
    if (phase === "processing") return;
    if (phase === "done") setPhraseIndex((i) => (i + 1) % phrases.length);
    setCount(0);
    setSeconds(0);
    setPhase("listening");
  };
  const cancel = () => {
    setDemo(false);
    setCount(0);
    setSeconds(0);
    setPhase("idle");
  };

  const listening = phase === "listening";
  const label = { idle: "Tap the mic to start", listening: "Listening", processing: "Working on it", done: "Done" }[phase];

  return (
    <section ref={rootRef} className="relative overflow-hidden bg-rose-50 px-4 py-14 text-slate-900 sm:px-6 sm:py-20">
      <style>{`
        @keyframes voice-input-bar-ring { from { transform: scale(1); opacity: .55; } to { transform: scale(2.1); opacity: 0; } }
        .voice-input-bar-ring { animation: voice-input-bar-ring 1.8s cubic-bezier(.2,.6,.3,1) infinite; }
        @media (prefers-reduced-motion: reduce) { .voice-input-bar-ring { animation: none; opacity: 0; } }
      `}</style>
      <div aria-hidden="true" className="pointer-events-none absolute -left-24 top-0 h-80 w-80 rounded-full bg-orange-200/70 blur-3xl" />
      <div aria-hidden="true" className="pointer-events-none absolute -right-20 bottom-0 h-96 w-96 rounded-full bg-fuchsia-200/70 blur-3xl" />

      <div className="relative mx-auto max-w-2xl">
        <p className="flex items-center gap-2 text-sm font-medium text-rose-700">
          <span className={`h-2 w-2 rounded-full ${listening ? "bg-rose-500" : phase === "done" ? "bg-emerald-500" : "bg-slate-400"}`} />
          <span aria-live="polite">{label}</span>
          <span className="text-slate-500">ยท Fieldnote voice assistant</span>
        </p>

        <p className="mt-5 min-h-[11rem] text-2xl font-semibold leading-snug tracking-tight sm:min-h-[8.5rem] sm:text-4xl sm:leading-tight" aria-label={count > 0 ? words.slice(0, count).join(" ") : undefined}>
          {count === 0 ? (
            <span className="text-slate-400">{phase === "idle" ? "Say what needs doing. I will sort it into jobs." : "Go ahead, I am listening..."}</span>
          ) : (
            words.slice(0, count).map((word, i) => (
              <motion.span key={`${phraseIndex}-${i}`} aria-hidden="true" initial={reduce ? false : { opacity: 0, y: 10, filter: "blur(6px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }} className={`inline-block whitespace-pre transition-colors duration-500 ${listening && i >= count - 2 ? "text-rose-500" : "text-slate-900"}`}>
                {word}{" "}
              </motion.span>
            ))
          )}
        </p>


        <motion.div initial={reduce ? false : { opacity: 0, y: 30, scale: 0.96 }} animate={{ opacity: 1, y: 0, scale: 1 }} transition={{ type: "spring", stiffness: 300, damping: 24 }} className="relative mt-2">
          <motion.div aria-hidden="true" animate={{ opacity: listening ? 0.75 : phase === "processing" ? 0.45 : 0.2 }} transition={{ duration: 0.6 }} className="absolute inset-x-10 -bottom-2 h-12 rounded-full bg-gradient-to-r from-fuchsia-400 via-rose-400 to-orange-400 blur-2xl" />
          <div className="relative flex items-center gap-3 rounded-full bg-white p-2.5 shadow-xl shadow-rose-900/10 ring-1 ring-rose-100 sm:gap-5 sm:p-3.5">
          <span className="relative flex-none">
            {listening && (
              <>
                <span aria-hidden="true" className="voice-input-bar-ring absolute inset-0 rounded-full bg-rose-400" />
                <span aria-hidden="true" className="voice-input-bar-ring absolute inset-0 rounded-full bg-rose-400" style={{ animationDelay: "0.9s" }} />
              </>
            )}
            <motion.button type="button" onClick={toggle} whileTap={{ scale: 0.92 }} aria-pressed={listening} aria-label={listening ? "Stop listening" : "Start listening"} className={`relative grid h-14 w-14 place-items-center rounded-full text-white sm:h-16 sm:w-16 transition-colors duration-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-600 focus-visible:ring-offset-2 ${listening ? "bg-gradient-to-br from-rose-500 to-orange-500" : "bg-slate-900"}`}>
              <AnimatePresence mode="wait" initial={false}>
                <motion.span key={phase === "processing" ? "busy" : listening ? "stop" : "mic"} initial={{ opacity: 0, scale: 0.4 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.4 }} transition={{ duration: 0.15 }}>
                  {phase === "processing" ? <span className="block h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white motion-reduce:animate-none" /> : listening ? <Square aria-hidden="true" className="h-5 w-5" fill="currentColor" /> : <Mic aria-hidden="true" className="h-6 w-6" />}
                </motion.span>
              </AnimatePresence>
            </motion.button>
          </span>

          <div aria-hidden="true" className="flex h-14 min-w-0 flex-1 items-center justify-between gap-[2px] sm:h-16 sm:gap-[3px]">
            {Array.from({ length: BARS }, (_, i) => (
              <span
                key={i}
                ref={(el) => {
                  barRefs.current[i] = el;
                }}
                className={`h-full min-w-0 max-w-[6px] flex-1 rounded-full ${i % 2 ? "hidden sm:block" : ""}`}
                style={{ transform: `scaleY(${restShape(i).toFixed(3)})`, backgroundColor: `hsl(${(322 + (i / BARS) * 64) % 360} 88% 60%)`, willChange: "transform" }}
              />
            ))}
          </div>

          <span className="w-9 flex-none text-right text-sm font-medium tabular-nums text-slate-600">0:{String(seconds).padStart(2, "0")}</span>
          <button type="button" onClick={cancel} aria-label="Cancel recording" className="grid h-11 w-11 flex-none place-items-center rounded-full bg-slate-100 text-slate-600 transition hover:bg-slate-200 hover:text-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-600">
            <X aria-hidden="true" className="h-4 w-4" />
          </button>
          </div>
        </motion.div>
        <ul className="mt-5 grid min-h-[9.5rem] content-start gap-3 sm:min-h-[4.75rem] sm:grid-cols-2" aria-label="Actions taken">
          <AnimatePresence>
            {phase === "done" &&
              phrase.results.map((result, i) => (
                <motion.li key={`${phraseIndex}-${result.title}`} initial={reduce ? false : { opacity: 0, y: 18, scale: 0.94 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, scale: 0.96, transition: { duration: 0.15 } }} transition={{ type: "spring", stiffness: 380, damping: 24, delay: 0.1 + i * 0.12 }} className="flex items-center gap-3 rounded-2xl border border-rose-100 bg-white p-3 shadow-sm">
                  <span className="grid h-10 w-10 flex-none place-items-center rounded-xl bg-rose-100 text-rose-700">
                    <result.icon aria-hidden="true" className="h-5 w-5" />
                  </span>
                  <span className="min-w-0 flex-1">
                    <span className="block text-sm font-semibold">{result.title}</span>
                    <span className="block truncate text-xs text-slate-600">{result.detail}</span>
                  </span>
                  <Check aria-hidden="true" className="h-4 w-4 flex-none text-emerald-600" strokeWidth={3} />
                </motion.li>
              ))}
          </AnimatePresence>
        </ul>
        <p className="mt-2 text-center text-xs text-slate-600">Simulated demo. Tap the mic to stop, or to dictate the next note.</p>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/voice-input-bar-with-live-waveform.json

When to use it

How to customize it

Accessibility

Questions

How is the waveform animated?

One `requestAnimationFrame` loop computes a target height for each bar and eases the current height towards it, then writes `scaleY` directly onto the element through a ref. React does not render during the loop, and only `transform` changes, so it stays smooth.

How do I drive it from a real microphone?

Create an `AnalyserNode` from a `getUserMedia` stream, read `getByteFrequencyData` inside the loop, and use those values in place of the `target` calculation. Keep the easing line so the bars do not flicker. Ask for permission only after the person presses the mic.

Why do the bars move with the words?

Each time a word is added the `energy` value is set to 1, and the loop lets it decay. Bar height is multiplied by that value, so the waveform swells on each word and settles in the pauses.

Does the loop run when the component is not visible?

No. The effect only starts the loop while the section is in the viewport, and it cancels the frame when the browser tab is hidden. The script timers follow the same rule.

Can I use the transcript effect by itself?

Yes. The transcript is a list of `motion.span` words that fade up from a blur. Copy that block and feed it any array of words.

Related: Glowing AI Prompt Box with Morphing Send Button, Streaming AI Chat Thread with Typing Indicator, Assistant Side Panel with Thinking Shimmer.

More ai prompt and chat ui or all free UI components. Start for free to have a full site built around it.

More in AI Chat

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap