diff --git a/ui/src/components/VoiceMicButton.tsx b/ui/src/components/VoiceMicButton.tsx
new file mode 100644
index 00000000..6fa8a566
--- /dev/null
+++ b/ui/src/components/VoiceMicButton.tsx
@@ -0,0 +1,60 @@
+import { Mic, Loader2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { VoiceWaveform } from "./VoiceWaveform";
+import { useVadRecorder } from "../hooks/useVadRecorder";
+
+interface VoiceMicButtonProps {
+ onTranscript: (text: string) => void;
+ disabled?: boolean;
+}
+
+export function VoiceMicButton({ onTranscript, disabled }: VoiceMicButtonProps) {
+ const { state, start, stop, mediaStream } = useVadRecorder({ onTranscript });
+
+ // Idle state (also used when disabled)
+ if (state === "idle") {
+ return (
+
+ );
+ }
+
+ // Recording state — show waveform with primary ring, click to stop
+ if (state === "recording") {
+ return (
+
+ );
+ }
+
+ // Processing state — transcribing, disabled
+ return (
+
+ );
+}
diff --git a/ui/src/components/VoiceWaveform.tsx b/ui/src/components/VoiceWaveform.tsx
new file mode 100644
index 00000000..b1d4f2ab
--- /dev/null
+++ b/ui/src/components/VoiceWaveform.tsx
@@ -0,0 +1,98 @@
+import { useRef, useEffect } from "react";
+
+interface VoiceWaveformProps {
+ stream: MediaStream | null;
+ active: boolean; // controls animation loop
+}
+
+export function VoiceWaveform({ stream, active }: VoiceWaveformProps) {
+ const canvasRef = useRef(null);
+ const audioCtxRef = useRef(null);
+ const analyserRef = useRef(null);
+ const sourceRef = useRef(null);
+ const animFrameRef = useRef(null);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas || !stream || !active) return;
+
+ // Create or resume AudioContext (lazily — reused across start/stop cycles)
+ if (!audioCtxRef.current) {
+ audioCtxRef.current = new AudioContext();
+ }
+ const audioCtx = audioCtxRef.current;
+
+ if (audioCtx.state === "suspended") {
+ audioCtx.resume();
+ }
+
+ // Set up analyser
+ const analyser = audioCtx.createAnalyser();
+ analyser.fftSize = 64; // 32 frequency bins
+ analyserRef.current = analyser;
+
+ const source = audioCtx.createMediaStreamSource(stream);
+ sourceRef.current = source;
+ source.connect(analyser);
+
+ const dataArray = new Uint8Array(analyser.frequencyBinCount); // 32 bins
+ const canvasWidth = canvas.width;
+ const canvasHeight = canvas.height;
+ const ctx2d = canvas.getContext("2d");
+
+ // Get primary color from CSS variable
+ const primaryColor =
+ getComputedStyle(document.documentElement).getPropertyValue("--primary").trim() ||
+ "#1e66f5";
+
+ const draw = () => {
+ analyser.getByteFrequencyData(dataArray);
+
+ if (ctx2d) {
+ ctx2d.clearRect(0, 0, canvasWidth, canvasHeight);
+ ctx2d.fillStyle = primaryColor;
+
+ // Draw 20 bars, skipping every other bin (using bins 0, 2, 4, ... 38)
+ const barCount = 20;
+ const barWidth = 2;
+ const barGap = 2;
+ const totalWidth = barCount * barWidth + (barCount - 1) * barGap;
+ const startX = Math.floor((canvasWidth - totalWidth) / 2);
+
+ for (let i = 0; i < barCount; i++) {
+ const binValue = dataArray[i * 2] ?? 0;
+ const barHeight = Math.max(2, (binValue / 255) * canvasHeight);
+ const x = startX + i * (barWidth + barGap);
+ const y = canvasHeight - barHeight;
+ ctx2d.fillRect(x, y, barWidth, barHeight);
+ }
+ }
+
+ animFrameRef.current = requestAnimationFrame(draw);
+ };
+
+ animFrameRef.current = requestAnimationFrame(draw);
+
+ return () => {
+ // Cleanup on unmount or when active becomes false
+ if (animFrameRef.current !== null) {
+ cancelAnimationFrame(animFrameRef.current);
+ animFrameRef.current = null;
+ }
+ source.disconnect();
+ sourceRef.current = null;
+ analyserRef.current = null;
+ // Do NOT close AudioContext — reuse across start/stop cycles
+ };
+ }, [stream, active]);
+
+ return (
+
+ );
+}