"use client";
import * as React from "react";
import { AnimatePresence, motion, useMotionValue, useTransform } from "motion/react";
type RecordButtonSize = "sm" | "md" | "lg";
type Phase = "idle" | "requesting" | "recording" | "recorded";
export interface RecordButtonResult {
/** The recorded audio. Always null in simulated mode. */
blob: Blob | null;
/** Length in seconds (one decimal). */
duration: number;
/** MIME type chosen by MediaRecorder, or null in simulated mode. */
mimeType: string | null;
}
export interface RecordButtonProps
extends Omit<
React.ComponentPropsWithoutRef<"div">,
"children" | "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd"
> {
/**
* "simulated" fakes the input level and needs no permission. "microphone" calls getUserMedia
* only when the user presses record, and stops every track on stop and unmount.
*/
mode?: "simulated" | "microphone";
/** Recording stops automatically after this many seconds. */
maxDuration?: number;
/** Visible title; also names the record button. */
label?: string;
/** Record glyph, level ring and played waveform colour. */
accent?: string;
size?: RecordButtonSize;
/** The ring around the button follows the input level while recording. */
pulse?: boolean;
/** Input level gain (microphone mode and the simulated meter). */
sensitivity?: number;
onRecordingStart?: () => void;
/** Called when a recording stops (by the user or at maxDuration), with the Blob in microphone mode. */
onRecorded?: (result: RecordButtonResult) => void;
/** Called when the user keeps the recording with the Done button. */
onDone?: (result: RecordButtonResult) => void;
/** Called when the user discards the recording. */
onDiscard?: () => void;
}
const SIZES: Record<RecordButtonSize, { button: number; text: string }> = {
sm: { button: 44, text: "text-[13px]" },
md: { button: 56, text: "text-sm" },
lg: { button: 68, text: "text-[15px]" },
};
const BARS = 36;
const SAMPLE_MS = 80;
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/** m:ss */
function formatTime(seconds: number) {
const s = Math.max(0, Math.floor(seconds));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
/** A speech-like fake level: syllables inside phrases with occasional breaths. */
function simulatedLevel(t: number) {
const syllable = Math.max(0, Math.sin(t * 7.3) * Math.sin(t * 2.9 + 1.3));
const phrase = 0.55 + 0.45 * Math.sin(t * 0.9);
const breath = Math.sin(t * 0.37 + 0.6) > 0.8 ? 0.12 : 1;
return Math.min(1, 0.06 + syllable * phrase * breath + Math.random() * 0.1);
}
/** Buckets raw samples into `count` bars, normalised to the loudest one. */
function toBars(samples: number[], count: number) {
if (!samples.length) return Array.from({ length: count }, () => 0.15);
const out: number[] = [];
for (let i = 0; i < count; i++) {
const from = Math.floor((i / count) * samples.length);
const to = Math.max(from + 1, Math.floor(((i + 1) / count) * samples.length));
let peak = 0;
for (let j = from; j < to && j < samples.length; j++) peak = Math.max(peak, samples[j]);
out.push(peak);
}
const max = Math.max(...out, 0.01);
return out.map((v) => Number((0.14 + 0.86 * (v / max)).toFixed(3)));
}
function microphoneError(error: unknown) {
const name = error instanceof DOMException ? error.name : "";
if (name === "NotAllowedError" || name === "SecurityError") return "Microphone access was blocked. Allow it in your browser's site settings to record.";
if (name === "NotFoundError" || name === "OverconstrainedError") return "No microphone was found.";
if (name === "NotReadableError") return "The microphone is in use by another app.";
return "The recording couldn't start.";
}
/**
* A voice-note record button: idle → recording (the circle morphs into a stop square, a ring pulses
* with the input level and a timer runs) → recorded (play back, discard or keep). Simulated mode needs
* no permissions; microphone mode asks only when the user presses record.
*/
export function RecordButton({
mode = "simulated",
maxDuration = 60,
label = "Voice note",
accent = "#FF7448",
size = "md",
pulse = true,
sensitivity = 1,
onRecordingStart,
onRecorded,
onDone,
onDiscard,
className = "",
style,
...props
}: RecordButtonProps) {
const reduceMotion = usePrefersReducedMotion();
const errorId = `${React.useId()}-error`;
const s = SIZES[size] ?? SIZES.md;
const maxSeconds = Math.max(1, maxDuration);
const [phase, setPhase] = React.useState<Phase>("idle");
const [elapsed, setElapsed] = React.useState(0);
const [duration, setDuration] = React.useState(0);
const [bars, setBars] = React.useState<number[]>([]);
const [playing, setPlaying] = React.useState(false);
const [message, setMessage] = React.useState("");
const [errorText, setErrorText] = React.useState("");
const rootRef = React.useRef<HTMLDivElement>(null);
const recordRef = React.useRef<HTMLButtonElement>(null);
const playRef = React.useRef<HTMLButtonElement>(null);
const focusNext = React.useRef<"record" | "play" | null>(null);
const inView = React.useRef(true);
const level = useMotionValue(0);
const progress = useMotionValue(0);
const ringScale = useTransform(level, [0, 1], [1.04, 1.55]);
const ringOpacity = useTransform(level, [0, 1], [0.22, 0.55]);
const playedClip = useTransform(progress, (p) => `inset(0 ${Number((100 - p * 100).toFixed(2))}% 0 0)`);
// Everything the recording session owns. Refs: none of it should re-render the component.
const session = React.useRef({
start: 0,
raf: 0,
interval: 0,
smoothed: 0,
samples: [] as number[],
stream: null as MediaStream | null,
audioContext: null as AudioContext | null,
analyser: null as AnalyserNode | null,
buffer: null as Uint8Array<ArrayBuffer> | null,
recorder: null as MediaRecorder | null,
chunks: [] as Blob[],
result: null as RecordButtonResult | null,
url: "",
audio: null as HTMLAudioElement | null,
playRaf: 0,
playFrom: 0,
playStart: 0,
cancelled: false,
});
// Latest props for long-lived callbacks (intervals, recorder events).
const latest = React.useRef({ onRecordingStart, onRecorded, onDone, onDiscard, sensitivity, maxSeconds, mode, pulse, reduceMotion });
React.useEffect(() => {
latest.current = { onRecordingStart, onRecorded, onDone, onDiscard, sensitivity, maxSeconds, mode, pulse, reduceMotion };
});
React.useEffect(() => {
const node = rootRef.current;
if (!node || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(([entry]) => (inView.current = entry.isIntersecting));
io.observe(node);
return () => io.disconnect();
}, []);
// Move focus to whichever main button replaced the one the user just pressed.
React.useEffect(() => {
const target = focusNext.current;
focusNext.current = null;
if (target === "play") playRef.current?.focus({ preventScroll: true });
if (target === "record") recordRef.current?.focus({ preventScroll: true });
}, [phase]);
const readLevel = React.useCallback(() => {
const sess = session.current;
const gain = latest.current.sensitivity;
if (sess.analyser && sess.buffer) {
sess.analyser.getByteTimeDomainData(sess.buffer);
let sum = 0;
for (let i = 0; i < sess.buffer.length; i++) {
const v = (sess.buffer[i] - 128) / 128;
sum += v * v;
}
return Math.min(1, Math.sqrt(sum / sess.buffer.length) * 4.5 * gain);
}
return Math.min(1, simulatedLevel((performance.now() - sess.start) / 1000) * gain);
}, []);
/** Releases the microphone and audio graph. Safe to call repeatedly. */
const releaseInput = React.useCallback(() => {
const sess = session.current;
cancelAnimationFrame(sess.raf);
window.clearInterval(sess.interval);
sess.stream?.getTracks().forEach((track) => track.stop());
sess.stream = null;
if (sess.audioContext && sess.audioContext.state !== "closed") sess.audioContext.close().catch(() => {});
sess.audioContext = null;
sess.analyser = null;
sess.buffer = null;
}, []);
const stopPlayback = React.useCallback(
(resetPosition: boolean) => {
const sess = session.current;
cancelAnimationFrame(sess.playRaf);
sess.audio?.pause();
if (resetPosition) {
sess.playFrom = 0;
if (sess.audio) sess.audio.currentTime = 0;
progress.set(0);
}
setPlaying(false);
},
[progress],
);
const clearRecording = React.useCallback(() => {
const sess = session.current;
stopPlayback(true);
if (sess.url) URL.revokeObjectURL(sess.url);
sess.url = "";
sess.audio = null;
sess.result = null;
sess.chunks = [];
}, [stopPlayback]);
// Unmount: stop everything, including the microphone.
React.useEffect(() => {
const sess = session.current;
// Reset on (re)mount: Strict Mode runs this cleanup once before the real mount.
sess.cancelled = false;
return () => {
sess.cancelled = true;
if (sess.recorder && sess.recorder.state !== "inactive") {
sess.recorder.onstop = null;
sess.recorder.stop();
}
releaseInput();
cancelAnimationFrame(sess.playRaf);
sess.audio?.pause();
if (sess.url) URL.revokeObjectURL(sess.url);
};
}, [releaseInput]);
const stopRecording = React.useCallback(
(reason: "user" | "max") => {
const sess = session.current;
const seconds = Math.min(latest.current.maxSeconds, (performance.now() - sess.start) / 1000);
const rounded = Math.round(seconds * 10) / 10;
const samples = sess.samples;
releaseInput();
level.set(0);
const recorder = sess.recorder;
sess.recorder = null;
if (recorder && recorder.state !== "inactive") {
recorder.onstop = () => {
if (sess.cancelled) return;
const type = recorder.mimeType || sess.chunks[0]?.type || "audio/webm";
const blob = new Blob(sess.chunks, { type });
sess.url = URL.createObjectURL(blob);
sess.result = { blob, duration: rounded, mimeType: type };
latest.current.onRecorded?.(sess.result);
};
recorder.stop();
} else {
sess.result = { blob: null, duration: rounded, mimeType: null };
latest.current.onRecorded?.(sess.result);
}
if (reason === "user" || document.activeElement === recordRef.current) focusNext.current = "play";
setBars(toBars(samples, BARS));
setDuration(rounded);
setPhase("recorded");
setMessage(`${reason === "max" ? "Maximum length reached. " : ""}Recording stopped, ${formatTime(rounded)}`);
},
[level, releaseInput],
);
/** Starts the level meter, timer and auto-stop once input is ready. */
const beginRecording = React.useCallback(() => {
const sess = session.current;
sess.start = performance.now();
sess.samples = [];
sess.smoothed = 0;
setElapsed(0);
setPhase("recording");
setMessage("Recording started");
latest.current.onRecordingStart?.();
// Timer, waveform samples and auto-stop: an interval, so it keeps time offscreen too.
sess.interval = window.setInterval(() => {
const t = (performance.now() - sess.start) / 1000;
sess.samples.push(readLevel());
setElapsed(Math.floor(t));
if (t >= latest.current.maxSeconds) stopRecording("max");
}, SAMPLE_MS);
// Visual level: rAF, idle while offscreen (and naturally paused in hidden tabs).
const frame = () => {
sess.raf = requestAnimationFrame(frame);
if (!inView.current) return;
const target = latest.current.pulse && !latest.current.reduceMotion ? readLevel() : 0.35;
sess.smoothed += (target - sess.smoothed) * 0.25;
level.set(sess.smoothed);
};
sess.raf = requestAnimationFrame(frame);
}, [level, readLevel, stopRecording]);
const startRecording = async () => {
setErrorText("");
clearRecording();
const sess = session.current;
if (latest.current.mode !== "microphone") {
beginRecording();
return;
}
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) {
const text = "Recording isn't available here. It needs a secure (https) page and microphone support.";
setErrorText(text);
setMessage(text);
return;
}
setPhase("requesting");
setMessage("Waiting for microphone permission");
try {
// The only place permission is requested: in direct response to the user pressing record.
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (sess.cancelled) {
stream.getTracks().forEach((track) => track.stop());
return;
}
sess.stream = stream;
const AudioContextClass = window.AudioContext;
if (AudioContextClass) {
const context = new AudioContextClass();
const analyser = context.createAnalyser();
analyser.fftSize = 1024;
context.createMediaStreamSource(stream).connect(analyser);
sess.audioContext = context;
sess.analyser = analyser;
sess.buffer = new Uint8Array(new ArrayBuffer(analyser.fftSize));
}
sess.chunks = [];
if (typeof MediaRecorder !== "undefined") {
const recorder = new MediaRecorder(stream);
recorder.ondataavailable = (event) => {
if (event.data.size) sess.chunks.push(event.data);
};
recorder.start();
sess.recorder = recorder;
}
beginRecording();
} catch (error) {
releaseInput();
if (sess.cancelled) return;
const text = microphoneError(error);
focusNext.current = "record";
setPhase("idle");
setErrorText(text);
setMessage(text);
}
};
const togglePlayback = () => {
const sess = session.current;
if (playing) {
if (sess.audio) sess.playFrom = sess.audio.currentTime;
else sess.playFrom += (performance.now() - sess.playStart) / 1000;
stopPlayback(false);
return;
}
const total = Math.max(0.1, duration);
if (sess.playFrom >= total - 0.05) sess.playFrom = 0;
if (sess.url) {
if (!sess.audio) sess.audio = new Audio(sess.url);
const audio = sess.audio;
audio.currentTime = sess.playFrom;
audio.onended = () => stopPlayback(true);
audio.play().catch(() => stopPlayback(true));
}
sess.playStart = performance.now();
setPlaying(true);
const frame = () => {
const position = sess.audio ? sess.audio.currentTime : sess.playFrom + (performance.now() - sess.playStart) / 1000;
progress.set(Math.min(1, position / total));
if (!sess.audio && position >= total) {
stopPlayback(true);
return;
}
sess.playRaf = requestAnimationFrame(frame);
};
sess.playRaf = requestAnimationFrame(frame);
};
const discard = () => {
clearRecording();
focusNext.current = "record";
setPhase("idle");
setMessage("Recording discarded");
latest.current.onDiscard?.();
};
const done = () => {
const sess = session.current;
const result = sess.result ?? { blob: null, duration, mimeType: null };
stopPlayback(true);
latest.current.onDone?.(result);
clearRecording();
focusNext.current = "record";
setPhase("idle");
setMessage(`Recording done, ${formatTime(duration)}`);
};
const recording = phase === "recording";
const recorded = phase === "recorded";
const swap = reduceMotion
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0.12 } }
: {
initial: { opacity: 0, scale: 0.6 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.6 },
transition: { type: "spring" as const, stiffness: 520, damping: 32 },
};
const iconButton =
"grid size-9 shrink-0 place-items-center rounded-full text-zinc-500 outline-hidden transition-colors hover:bg-zinc-900/[0.06] hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-solid focus-visible:outline-[var(--rb-accent)] dark:text-zinc-400 dark:hover:bg-white/[0.08] dark:hover:text-zinc-50";
const mainButton = `grid shrink-0 place-items-center rounded-full border border-zinc-900/10 bg-white shadow-[0_1px_0_rgb(0_0_0/0.04),0_8px_20px_-12px_rgb(0_0_0/0.45)] outline-hidden transition-colors hover:border-zinc-900/20 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--rb-accent)] dark:border-white/10 dark:bg-zinc-900 dark:shadow-none dark:hover:border-white/20`;
return (
<div
{...props}
ref={rootRef}
role="group"
aria-label={label}
className={`relative flex w-full max-w-[380px] items-center gap-3 rounded-full border border-zinc-900/10 bg-white/70 p-2 pr-3 dark:border-white/10 dark:bg-white/[0.03] ${className}`}
style={{ ["--rb-accent" as string]: accent, ...style }}
>
<div className="relative shrink-0" style={{ width: s.button, height: s.button }}>
{/* Level ring (decorative) */}
{recording ? (
<motion.span
aria-hidden="true"
className="pointer-events-none absolute inset-0 rounded-full bg-[var(--rb-accent)]"
style={reduceMotion || !pulse ? { scale: 1.14, opacity: 0.22 } : { scale: ringScale, opacity: ringOpacity }}
/>
) : null}
<AnimatePresence initial={false}>
{recorded ? (
<motion.button
key="play"
ref={playRef}
type="button"
aria-label={playing ? "Pause playback" : "Play recording"}
onClick={togglePlayback}
className={`${mainButton} absolute inset-0 text-zinc-900 dark:text-zinc-50`}
{...swap}
>
<svg viewBox="0 0 24 24" aria-hidden="true" className="size-[38%]" fill="currentColor">
{playing ? <path d="M7 5h3.2v14H7zM13.8 5H17v14h-3.2z" /> : <path d="M8 5.2v13.6a.8.8 0 0 0 1.2.7l10.6-6.8a.8.8 0 0 0 0-1.4L9.2 4.5a.8.8 0 0 0-1.2.7Z" />}
</svg>
</motion.button>
) : (
<motion.button
key="record"
ref={recordRef}
type="button"
aria-label={`Record ${label.toLowerCase()}`}
aria-pressed={recording}
aria-busy={phase === "requesting" || undefined}
aria-describedby={errorText ? errorId : undefined}
onClick={() => {
if (phase === "requesting") return;
if (recording) stopRecording("user");
else void startRecording();
}}
className={`${mainButton} absolute inset-0`}
{...swap}
>
{/* Circle ⇄ rounded square */}
<motion.span
aria-hidden="true"
className="block bg-[var(--rb-accent)]"
initial={false}
animate={{
width: recording ? "34%" : "42%",
height: recording ? "34%" : "42%",
borderRadius: recording ? "22%" : "50%",
opacity: phase === "requesting" ? 0.45 : 1,
}}
transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 26 }}
/>
</motion.button>
)}
</AnimatePresence>
</div>
<div className="min-w-0 flex-1">
{recorded ? (
<div className="flex items-center gap-2.5">
{/* Waveform: muted bars with an accent copy clipped to the playback position. */}
<div aria-hidden="true" className="relative h-8 min-w-0 flex-1">
<Bars bars={bars} className="bg-zinc-900/20 dark:bg-white/25" />
<motion.div className="absolute inset-0" style={{ clipPath: playedClip }}>
<Bars bars={bars} className="bg-[var(--rb-accent)]" />
</motion.div>
</div>
<span className="shrink-0 font-mono text-[11px] tabular-nums text-zinc-600 dark:text-zinc-300">{formatTime(duration)}</span>
</div>
) : (
<div className="flex flex-col gap-1">
<span className={`flex items-center gap-2 font-medium tracking-tight text-zinc-900 dark:text-zinc-50 ${s.text}`}>
{recording ? (
<>
<span aria-hidden="true" className="size-1.5 rounded-full bg-[var(--rb-accent)] motion-safe:animate-pulse" />
Recording
</>
) : phase === "requesting" ? (
"Waiting for microphone…"
) : (
label
)}
</span>
{errorText ? (
<span id={errorId} className="text-[12px] leading-snug text-[#D93D42] dark:text-[#FF8A8E]">
{errorText}
</span>
) : (
<span className="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
<span className="tabular-nums">
{recording ? formatTime(elapsed) : "Tap to record"} / {formatTime(maxSeconds)}
</span>
{recording ? (
<span aria-hidden="true" className="relative h-px min-w-6 flex-1 overflow-hidden bg-zinc-900/10 dark:bg-white/10">
<span
className="absolute inset-y-0 left-0 bg-[var(--rb-accent)] transition-[width] duration-100 ease-linear"
style={{ width: `${Math.min(100, Math.round((elapsed / maxSeconds) * 1000) / 10)}%` }}
/>
</span>
) : null}
</span>
)}
</div>
)}
</div>
{recorded ? (
<div className="flex shrink-0 items-center gap-0.5">
<button type="button" aria-label="Discard recording" onClick={discard} className={iconButton}>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="size-[18px]" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M4.5 7h15M9.5 7V5.2c0-.66.54-1.2 1.2-1.2h2.6c.66 0 1.2.54 1.2 1.2V7m-8.3 0 .8 11.1A2 2 0 0 0 9 20h6a2 2 0 0 0 2-1.9L17.8 7" />
</svg>
</button>
<button
type="button"
aria-label="Done"
onClick={done}
className="grid size-9 shrink-0 place-items-center rounded-full bg-zinc-900 text-white outline-hidden transition-[background-color,scale] hover:bg-zinc-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--rb-accent)] active:scale-95 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-white"
>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="size-[18px]" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m5.5 12.5 4.2 4.2 8.8-9.4" />
</svg>
</button>
</div>
) : null}
<span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{message}
</span>
</div>
);
}
function Bars({ bars, className }: { bars: number[]; className: string }) {
return (
<div className="absolute inset-0 flex items-center gap-[2px]">
{bars.map((h, i) => (
<span key={i} className={`min-w-0 flex-1 rounded-full ${className}`} style={{ height: `${Math.round(h * 100)}%` }} />
))}
</div>
);
}