Teseria
All elements

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

Terminal
npm i framer-motion lucide-react

2. Files

VoiceNote.tsx262 lines
"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.css141 lines
/* 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;
}
tokens.cssshared · add once
/*
 * 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

Demo.tsx
import { VoiceNote } from "./VoiceNote";
import "./VoiceNote.css";

export default function Demo() {
  return <VoiceNote />;
}