Voice note
Hold to record. The pill opens into a live waveform with a timer; let go and it becomes a voice note you can play, the bars filling as it plays.
Code
1. Install
npm i framer-motion lucide-react
2. Files
"use client";
// VoiceNote: hold to record. The pill opens into a live waveform with a timer; let go and it becomes a voice note you can
// play, the bars filling as it plays. Space or Enter held on the pill records too. The level is a synthetic speech-like
// envelope: swap the interval for a real analyser to record for real.
import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react";
import { AnimatePresence, animate, motion, useMotionValue, useTransform } from "framer-motion";
import { Mic, Trash2 } from "lucide-react";
export type VoiceNoteCue = "hold" | "reject" | "done" | "push" | "bin";
export type VoiceNoteRecording = {
/** Length in milliseconds. */
ms: number;
/** Bar heights from 0 to 1, resampled to `bars`. */
levels: number[];
};
export type VoiceNoteProps = {
/** Number of waveform bars. */
bars?: number;
/** Shortest hold that counts as a note, in milliseconds. Shorter holds are discarded. */
minMs?: number;
/** Text on the idle pill. */
holdLabel?: string;
/** Called with the length and waveform when a note is recorded. */
onRecord?: (note: VoiceNoteRecording) => void;
/** Called when the note is deleted. */
onDelete?: () => void;
/** Sound hook: "hold" on record start, "reject" on a too-short hold, "done" when a note is saved, "push" on play or pause, "bin" on delete. */
onSound?: (cue: VoiceNoteCue) => void;
className?: string;
style?: CSSProperties;
};
type Mode = "idle" | "recording" | "saved";
const OUT = [0.22, 1, 0.36, 1] as const;
const spring = (stiffness: number, damping: number, mass: number) => ({ type: "spring" as const, stiffness, damping, mass });
const POP = spring(500, 18, 0.7);
const MORPH = spring(340, 30, 0.8);
const KEEP = 600; // samples kept while recording (~42s); a longer note shows only its tail
const limit = (x: number, min: number, max: number) => Math.max(min, Math.min(max, x));
const quiet = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const clock = (ms: number) => {
const secs = Math.floor(ms / 1000) % 60;
return `${Math.floor(ms / 60000)}:${secs < 10 ? "0" : ""}${secs}`;
};
const holdKey = (k: string) => k === " " || k === "Enter";
const grabPointer = (el: Element, id: number) => {
try {
el.setPointerCapture(id);
} catch {
/* the pointer is already gone */
}
};
/** Average the samples into `n` equal buckets; no samples gives a flat quiet line. */
function bucket(samples: number[], n: number) {
const out: number[] = [];
for (let i = 0; i < n; i++) {
const from = Math.floor((i * samples.length) / n);
const to = Math.max(from + 1, Math.floor(((i + 1) * samples.length) / n));
let sum = 0;
for (let k = from; k < to; k++) sum += samples[k] ?? 0.1;
out.push(samples.length ? sum / (to - from) : 0.1);
}
return out;
}
// Pause/play glyph: both bars and both triangle halves are 4-point polygons, so one 0..1 motion value morphs them
// (framer mixes the numbers inside the path strings). The glyph leans into the turn through the same value.
const BAR_L = "M6 4L10 4L10 20L6 20Z", TRI_L = "M6.5 4L13.25 8L13.25 16L6.5 20Z";
const BAR_R = "M14 4L18 4L18 20L14 20Z", TRI_R = "M13.25 8L20 12L20 12L13.25 16Z";
const GLYPH = { viewBox: "0 0 24 24", "aria-hidden": true, fill: "currentColor", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round" } as const;
function PlayPauseGlyph({ playing, size = 16 }: { playing: boolean; size?: number }) {
const t = useMotionValue(playing ? 0 : 1);
const left = useTransform(t, [0, 1], [BAR_L, TRI_L]);
const right = useTransform(t, [0, 1], [BAR_R, TRI_R]);
const lean = useTransform(t, [0, 0.5, 1], [0, 1, 0]);
const rot = useTransform(lean, (k) => `rotate(${(playing ? -9 : 9) * k}deg) scale(${1 - 0.13 * k}, ${1 + 0.11 * k})`);
const nudgeL = useTransform(lean, (k) => 1.6 * k);
const nudgeR = useTransform(lean, (k) => -1.6 * k);
useEffect(() => {
const run = animate(t, playing ? 0 : 1, { duration: quiet() ? 0 : 0.3, ease: OUT });
return () => run.stop();
}, [playing, t]);
return (
<motion.svg {...GLYPH} width={size} height={size} style={{ transformOrigin: "50% 50%", transform: rot }}>
<motion.path d={left} style={{ x: nudgeL }} />
<motion.path d={right} style={{ x: nudgeR }} />
</motion.svg>
);
}
/** Bar heights in px from a 0..1 level. */
const barH = (v: number) => `${4 + v * 20}px`;
function Bars({ values, className }: { values: number[]; className?: string }) {
return (
<span className={className ? `vnot-bars ${className}` : "vnot-bars"} aria-hidden>
{values.map((v, i) => (
<i key={i} style={{ height: barH(v) }} />
))}
</span>
);
}
const fade = { initial: { opacity: 0, filter: "blur(4px)" }, animate: { opacity: 1, filter: "blur(0px)", transitionEnd: { filter: "none" } }, exit: { opacity: 0, filter: "blur(4px)" }, transition: { duration: 0.16 } };
export function VoiceNote({ bars = 28, minMs = 400, holdLabel = "Hold to record", onRecord, onDelete, onSound, className, style }: VoiceNoteProps) {
const rootEl = useRef<HTMLDivElement>(null);
const sound = useRef(onSound);
sound.current = onSound;
const [mode, setMode] = useState<Mode>("idle");
const [samples, setSamples] = useState<number[]>([]);
const [elapsed, setElapsed] = useState(0);
const [playing, setPlaying] = useState(false);
const waveEl = useRef<HTMLSpanElement>(null);
const startedAt = useRef(0);
const drift = useRef(0.3);
const rewind = useRef(0);
useEffect(() => () => window.clearTimeout(rewind.current), []);
// the pulsing dot is an endless CSS animation: flag the root while it is far off screen so the stylesheet can pause it
useEffect(() => {
const el = rootEl.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(([hit]) => el.toggleAttribute("data-off", !hit.isIntersecting), { rootMargin: "160px" });
io.observe(el);
return () => io.disconnect();
}, []);
// the pill and the note swap places: focus follows to the button that took over
const was = useRef(mode);
useEffect(() => {
const next = mode === "saved" ? ".vnot-play" : mode === "idle" && was.current === "saved" ? ".vnot-pill" : "";
was.current = mode;
if (next && rootEl.current?.contains(document.activeElement)) rootEl.current.querySelector<HTMLElement>(next)?.focus();
}, [mode]);
// while recording: a clock and a synthetic speech-like level, sampled every 70ms
useEffect(() => {
if (mode !== "recording") return;
startedAt.current = performance.now();
const timer = setInterval(() => {
const t = performance.now() - startedAt.current;
const phrase = (Math.sin(t / 180) + Math.sin(t / 410) + 2) / 4;
drift.current = limit(drift.current + (Math.random() - 0.5) * 0.5, 0.12, 1);
const level = limit(drift.current * (0.4 + phrase * 0.8), 0.1, 1);
setSamples((prev) => (prev.length >= KEEP ? [...prev.slice(1), level] : [...prev, level]));
setElapsed(t);
}, 70);
return () => clearInterval(timer);
}, [mode]);
// while playing: sweep --vnot-p from 0 to 1 over the note's length
useEffect(() => {
if (!playing) return;
const t0 = performance.now();
let frame = 0;
const sweep = () => {
const done = limit((performance.now() - t0) / elapsed, 0, 1);
waveEl.current?.style.setProperty("--vnot-p", done.toFixed(4));
if (done < 1) {
frame = requestAnimationFrame(sweep);
return;
}
setPlaying(false);
window.clearTimeout(rewind.current);
rewind.current = window.setTimeout(() => waveEl.current?.style.setProperty("--vnot-p", "0"), 400);
};
frame = requestAnimationFrame(sweep);
return () => cancelAnimationFrame(frame);
}, [playing, elapsed]);
const begin = (pointer?: PointerEvent) => {
if (mode !== "idle") return;
if (pointer) grabPointer(pointer.currentTarget, pointer.pointerId);
onSound?.("hold");
setSamples([]);
setElapsed(0);
setMode("recording");
};
const finish = () => {
if (mode !== "recording") return;
const length = performance.now() - startedAt.current;
if (length < minMs) {
onSound?.("reject");
setMode("idle");
return;
}
onSound?.("done");
setElapsed(length);
setMode("saved");
onRecord?.({ ms: length, levels: bucket(samples, bars) });
};
const discard = () => {
onSound?.("bin");
setPlaying(false);
setMode("idle");
onDelete?.();
};
const recording = mode === "recording";
const live = recording ? samples.slice(-bars) : bucket(samples, bars);
const padded = Array.from({ length: bars }, (_, i) => live[live.length - bars + i] ?? 0.08);
return (
<div ref={rootEl} className={className ? `vnot-root ${className}` : "vnot-root"} style={style}>
<AnimatePresence mode="popLayout" initial={false}>
{mode !== "saved" ? (
<motion.button
key="pill"
type="button"
layout
className="vnot-pill"
data-rec={recording || undefined}
aria-label={recording ? "Recording, release to stop" : holdLabel}
onPointerDown={begin}
onPointerUp={finish}
onPointerCancel={finish}
onKeyDown={(e) => holdKey(e.key) && !e.repeat && (e.preventDefault(), begin())}
onKeyUp={(e) => holdKey(e.key) && finish()}
initial={{ opacity: 0, scale: 0.9, filter: "blur(6px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)", transitionEnd: { filter: "none" } }}
exit={{ opacity: 0, scale: 0.9, filter: "blur(6px)" }}
transition={{ ...MORPH, filter: { duration: 0.18 } }}
style={{ borderRadius: 999 }}
>
<span className="vnot-ico">{recording ? <span className="vnot-dot" /> : <Mic size={16} strokeWidth={2.2} />}</span>
<AnimatePresence mode="popLayout" initial={false}>
{recording ? (
<motion.span key="live" className="vnot-live" {...fade}>
<Bars values={padded} />
<span className="vnot-time">{clock(elapsed)}</span>
</motion.span>
) : (
<motion.span key="label" className="vnot-label" {...fade}>
{holdLabel}
</motion.span>
)}
</AnimatePresence>
</motion.button>
) : (
<motion.div key="note" layout className="vnot-note" initial={{ opacity: 0, scale: 0.9, y: 8, filter: "blur(6px)" }} animate={{ opacity: 1, scale: 1, y: 0, filter: "blur(0px)", transitionEnd: { filter: "none" } }} exit={{ opacity: 0, scale: 0.9, filter: "blur(6px)" }} transition={{ ...POP, filter: { duration: 0.18 } }}>
<button type="button" className="vnot-play" aria-label={playing ? "Pause" : "Play"} onClick={() => (onSound?.("push"), setPlaying(!playing))}>
<PlayPauseGlyph playing={playing} />
</button>
<span ref={waveEl} className="vnot-wave" aria-hidden style={{ "--vnot-p": 0 } as CSSProperties}>
<Bars values={live} className="vnot-under" />
<Bars values={live} className="vnot-over" />
</span>
<span className="vnot-time">{clock(elapsed)}</span>
<button type="button" className="vnot-del" aria-label="Delete voice note" onClick={discard}>
<Trash2 size={13} />
</button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
/* VoiceNote. Prefix vnot-. */
.vnot-root, .vnot-root * { box-sizing: border-box; }
:where(.vnot-root) button { font: inherit; color: inherit; background: none; border: 0; padding: 0; margin: 0; }
.vnot-root {
display: grid;
place-items: center;
width: 300px;
height: 100px;
user-select: none;
font-family: var(--ts-font, system-ui, sans-serif);
}
.vnot-pill,
.vnot-note {
display: inline-flex;
align-items: center;
gap: 10px;
height: 48px;
padding: 0 18px 0 14px;
background: var(--ts-fg);
color: var(--ts-fg-inverse);
font: inherit;
font-size: 14px;
font-weight: 500;
}
.vnot-pill {
cursor: pointer;
touch-action: none;
-webkit-tap-highlight-color: transparent;
}
.vnot-pill:focus-visible,
.vnot-play:focus-visible,
.vnot-del:focus-visible {
outline: 0;
box-shadow: 0 0 0 2px var(--ts-page), 0 0 0 4px var(--ts-accent);
}
.vnot-ico {
width: 20px;
height: 20px;
display: grid;
place-items: center;
flex: none;
}
.vnot-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--ts-danger);
animation: vnot-pulse 1s cubic-bezier(.22, 1, .36, 1) infinite;
}
@keyframes vnot-pulse {
50% {
transform: scale(0.7);
opacity: 0.6;
}
}
.vnot-live,
.vnot-label {
display: inline-flex;
align-items: center;
gap: 10px;
white-space: nowrap;
}
.vnot-bars {
display: inline-flex;
align-items: center;
gap: 2px;
height: 24px;
}
.vnot-bars i {
display: block;
width: 3px;
border-radius: 2px;
background: currentColor;
transition: height 0.12s cubic-bezier(.22, 1, .36, 1);
}
.vnot-time {
font-size: 12px;
font-variant-numeric: tabular-nums;
opacity: 0.7;
min-width: 30px;
}
.vnot-note {
border-radius: 999px;
padding: 0 8px 0 6px;
}
.vnot-play,
.vnot-del {
background: color-mix(in srgb, var(--ts-fg-inverse) 14%, transparent);
color: inherit;
cursor: pointer;
display: grid;
place-items: center;
border-radius: 50%;
transition: background 0.16s cubic-bezier(.22, 1, .36, 1), opacity 0.16s cubic-bezier(.22, 1, .36, 1), scale 0.22s cubic-bezier(.22, 1, .36, 1);
}
.vnot-play {
width: 36px;
height: 36px;
}
.vnot-del {
width: 28px;
height: 28px;
background: none;
opacity: 0.6;
}
.vnot-del:hover {
opacity: 1;
background: color-mix(in srgb, var(--ts-fg-inverse) 14%, transparent);
}
.vnot-play:active,
.vnot-del:active {
scale: 0.96;
}
.vnot-wave {
--vnot-p: 0; /* playback progress 0..1, written by script */
position: relative;
display: inline-grid;
}
.vnot-under,
.vnot-over {
grid-area: 1 / 1;
}
.vnot-under {
opacity: 0.35;
}
.vnot-over {
clip-path: inset(0 calc(100% - var(--vnot-p) * 100%) 0 0);
}
/* off screen: endless animations hold still (usePauseOffscreen sets data-off) */
.vnot-root[data-off],
.vnot-root[data-off] *,
.vnot-root[data-off] ::before,
.vnot-root[data-off] ::after,
.vnot-root[data-off]::before,
.vnot-root[data-off]::after {
animation-play-state: paused !important;
}
.vnot-play svg {
display: block;
}
/*
* Teseria tokens — the only colours, fonts and corner radii a block reads.
*
* Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
* the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
* any ancestor, or the OS setting unless [data-theme="light"] is set.
*
* Surfaces page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
* Text fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
* Lines border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
* dark; composes with other shadows)
* Signals accent · focus · success · warning · danger
* Corners controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
* (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
*/
:root {
--ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ts-r-panel: 24px;
--ts-r-pop: 18px;
--ts-r-inner: 12px;
--ts-page: #ffffff;
--ts-raised: #ffffff;
--ts-sunken: #f3f2f0;
--ts-tint-1: #f5f4f2;
--ts-tint-2: #ecebe8;
--ts-tint-3: #e3e1dd;
--ts-fg: #161514;
--ts-fg-rgb: 22, 21, 20;
--ts-fg-secondary: #3a3836;
--ts-fg-tertiary: #585552;
--ts-fg-muted: #6d6a66;
--ts-fg-faint: #89857f;
--ts-fg-inverse: #ffffff;
--ts-border: rgba(22, 21, 20, 0.1);
--ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);
--ts-accent: #3446eb;
--ts-focus: #3446eb;
--ts-success: #13804a;
--ts-warning: #a9650b;
--ts-danger: #df474e;
}
:root[data-theme="dark"] {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
}
3. Usage
import { VoiceNote } from "./VoiceNote";
import "./VoiceNote.css";
export default function Demo() {
return <VoiceNote />;
}