Media

Voice Recorder

A real MediaRecorder-backed voice recorder — live AnalyserNode waveform, pause/resume, mic-permission handling, and a listen-before-you-send preview.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertTriangle, Check, Loader2, Mic, MicOff, Pause, Play, RotateCcw, Square } from "lucide-react"
import { cn } from "@/lib/utils"

export type VoiceRecorderStatus =
  | "unsupported"
  | "idle"
  | "requesting"
  | "recording"
  | "paused"
  | "preview"
  | "denied"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/voice-recorder.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "VoiceRecorder" component using the
real browser MediaRecorder + Web Audio APIs — no fake waveforms, no canned
audio.

Contract
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement>.
- Props: onRecordingComplete(blob: Blob, durationMs: number) — required,
  fires when the user confirms a take; maxDurationMs (default 120_000,
  auto-stops the recording once elapsed); mimeType?: string (only honored
  if MediaRecorder.isTypeSupported approves it, else falls back through a
  candidate list); barCount (default 32, number of live waveform bars);
  showPlayback (default true, shows a play/pause control on the finished
  take); labels (partial copy override object); disabled (blocks *starting*
  a new recording — never traps one already in progress).

Behavior — state machine
- States: unsupported | idle | requesting | recording | paused | preview |
  denied | error.
- unsupported is a capability read (`typeof window.MediaRecorder !==
  "undefined" && navigator.mediaDevices?.getUserMedia`), never taken from
  the other 7 states — read it via useSyncExternalStore with a `false`
  server snapshot (capability checks can't be read at render time: SSR and
  first paint always render "unsupported", then silently re-render with the
  real client snapshot right after hydration).
- getUserMedia resolves only after the user answers the permission prompt, which
  can outlive the component: guard the resolution with a mounted ref and, if the
  component is gone, stop the returned stream's tracks right there. Without it the
  late resolution installs a live mic + rAF loop on an unmounted tree that nothing
  can release (the unmount cleanup already ran, and it could not see a stream that
  did not exist yet) — the browser's recording indicator stays lit until reload.
  Re-arm that ref on mount, or StrictMode's mount -> cleanup -> mount leaves it
  false for the live instance.
- idle → tap record → requesting → await getUserMedia({audio:true}):
  resolve → recording; reject with NotAllowedError/PermissionDeniedError →
  denied (show the reason + a Retry button that calls getUserMedia again,
  for real); any other DOMException/Error → error (show a generic message,
  optionally the caught error's message, + Retry).
- recording: build an AudioContext → createMediaStreamSource(stream) →
  createAnalyser() (fftSize 256), connect source→analyser ONLY (never to
  destination, or the mic echoes back out of the speakers). Create the
  MediaRecorder on the same stream, picking a mimeType via
  MediaRecorder.isTypeSupported. A requestAnimationFrame loop calls
  analyser.getByteFrequencyData, downsamples the frequency bins into
  `barCount` averaged buckets (0..1), and writes them into state for the
  bars. The same loop tracks elapsed time as accumulated-so-far +
  (performance.now() - segmentStart) so pause/resume math is exact, and
  auto-stops once elapsed >= maxDurationMs. Timer text is mm:ss, computed
  by hand (no Intl — locale-dependent formatting during SSR/hydration would
  desync from the client).
- Pause calls mediaRecorder.pause(), folds the elapsed segment into an
  accumulator, and cancels the rAF loop — the waveform freezes on its last
  frame instead of animating on stale data. Resume calls .resume(), starts
  a fresh segment clock, and restarts the loop.
- Stop (button or maxDurationMs) cancels the rAF loop, calls
  mediaRecorder.stop(), and — without waiting for the async "stop" event —
  immediately stops every stream track and closes the AudioContext, so the
  browser's recording indicator turns off right away. The "stop" event
  assembles the Blob from the collected chunks, creates one objectURL, and
  moves to preview.
- preview shows the duration, an optional play/pause control bound to the
  objectURL, "Re-record" (revokes the URL, discards the take, and jumps
  straight back into requesting) and "Use recording" (calls
  onRecordingComplete(blob, durationMs), revokes the URL, and resets to
  idle so the control is ready for another take).
- Cleanup is centralized: cancelAnimationFrame, mediaRecorder.stop() only
  if still active, stream.getTracks().forEach(t => t.stop()),
  audioContext.close(), URL.revokeObjectURL — all reachable from one
  teardown path that also runs on unmount (clearing the recorder's
  ondataavailable/onerror/onstop handlers first, so a late async event
  can't fire into an unmounted component).

Rendering & styling
- Semantic tokens only: bg-card/border for the shell, bg-primary +
  text-primary-foreground for the primary record/stop button and the
  waveform bars, bg-muted for secondary icon buttons and the idle waveform
  track, text-muted-foreground for supporting text, text-destructive /
  bg-destructive for the error icon and the recording indicator dot.
- Waveform bars are aria-hidden (the timer already conveys enough for
  assistive tech) and use transition-[height] for a smooth per-frame update,
  turned off via motion-reduce:transition-none.
- The recording indicator dot is a purely decorative pulse
  (motion-safe:animate-pulse) — it adds nothing functional beyond the
  "Recording" label + timer, so it's fully disabled under reduced motion
  instead of just slowed down.
- Timer: role="timer" aria-live="off" while ticking (announcing mm:ss every
  second would be constant screen-reader noise); a separate sr-only
  role="status" aria-live="polite" element announces the total duration
  once, exactly when the take finishes — the two are deliberately split so
  the live ticking never gets announced but the final result always does.
- All icon buttons: focus-visible:ring-2 ring-ring, disabled:opacity-50.

Customization levers
- Waveform density / feel: barCount (any positive int — the analyser's
  frequency bins get re-averaged into however many bars you ask for) and
  fftSize (256 by default; raise for finer frequency resolution at a
  slightly higher CPU cost).
- Recording ceiling: maxDurationMs — short for voice-message-style UIs,
  long (or a very large number) for voice-memo style.
- Preview surface: showPlayback=false to hide the built-in play/pause and
  drive your own playback UI from the confirmed Blob instead.
- Copy: labels accepts a partial override object (idle/requesting/
  recording/paused/preview/record/pause/resume/stop/reRecord/use/retry/
  denied/error/unsupported) — swap any subset for i18n or brand voice.
- Encoding: mimeType lets you request a specific container/codec; it's only
  applied if the browser's MediaRecorder.isTypeSupported agrees, otherwise
  the component silently falls back through a small candidate list.
- Palette: swap bg-primary / bg-muted / bg-destructive for other tokens
  (e.g. bg-chart-2) to match a chat bubble or brand accent color.

Concepts

  • Capability probe, not a state-machine branchunsupported is read once via useSyncExternalStore (server snapshot false) rather than being a transition any other state can reach; a browser either has MediaRecorder or it never leaves that branch.
  • Segment-accumulated timer — elapsed time is accumulated + (now - segmentStart), re-anchored on every pause/resume, so the displayed duration (and the maxDurationMs auto-stop check) is exact even across multiple pauses.
  • AnalyserNode waveform, never wired to output — the mic stream feeds an AnalyserNode for getByteFrequencyData only; it's deliberately never connected to audioContext.destination, or the user would hear their own voice echoed back live.
  • Split live-region announcement — the visible mm:ss timer is aria-live="off" (ticking it every second would be constant noise), while a separate aria-live="polite" region announces the finished duration exactly once, when the take completes.
  • Centralized teardown — one code path stops the MediaRecorder, stops every stream track, closes the AudioContext, cancels the animation frame, and revokes the object URL; it runs on Stop, on Re-record, and again (idempotently) on unmount, so a recording in progress can never leak a live mic after the component disappears.
  • Preview owns its own Blob URL — the objectURL created for playback is internal and revoked on Re-record/Use/unmount; the consumer receives the raw Blob via onRecordingComplete and is free to create its own URL from it.

On This Page