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…

Dependencies
lucide-react
Registry dependencies
utils, use-prefers-reduced-motion
"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"
import { getPrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"

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

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
- Shared primitive: use-prefers-reduced-motion (installed automatically via
  registryDependencies) — its one-shot getPrefersReducedMotion() is read each
  time the level loop starts (record / resume) to decide whether the bars
  update every frame or step at ~4 Hz. If rebuilding without it: read
  matchMedia("(prefers-reduced-motion: reduce)").matches at that moment.
- 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 covering every visible and
  spoken string); 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 (every frame, or at most every 250ms under prefers-reduced-motion so
  the bars step instead of jumping 60 times a second). 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.
  The cap does NOT rely on the loop alone: browsers suspend rAF in a hidden
  tab while MediaRecorder keeps recording, so start and resume also arm a
  setTimeout for maxDurationMs minus the accumulated time (cleared on pause,
  stop, error and unmount) — a take recorded in a background tab still ends
  at the cap. setTimeout stores its delay as a signed 32-bit int, so skip
  the timeout when the remaining time is above 2_147_483_647 ms (~24.8
  days), Infinity or NaN — otherwise it fires at once; such caps rest on
  the rAF check alone, so a very large maxDurationMs still means no
  practical cap. 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).
- Focus hand-off: every phase change unmounts the focused button (Record,
  Pause/Resume, Stop, Use, Retry each render in one phase only). When focus
  was inside the recorder at the action, remember a slot — "toggle" for
  Pause/Resume, "primary" otherwise — and in an effect keyed on the phase
  focus the new phase's control in that slot (Pause <-> Resume; Stop while
  recording or paused; Use recording in preview; Record in idle; Retry in
  denied/error), but only if document.activeElement fell back to <body>.
  "requesting" renders no control, so the request carries over to the phase
  after it.
- Cleanup is centralized: cancelAnimationFrame, clearTimeout on the cap,
  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 — and under reduced motion
  the loop also throttles the level updates to ~4 Hz, so the bars never
  jump every frame.
- 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, plus playPreview/pausePreview for the preview
  toggle, deniedHint for the line under the denied message, and complete
  for the "Recording complete — 0:12" announcement) — 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 branch — unsupported 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; the cap is also armed as a timeout for the remaining time (when it fits setTimeout's 32-bit delay), because a hidden tab suspends the animation frame but not the recorder.
  • 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