All patterns

Voice Message Play

The waveform fills left to right as the clip plays, with a head riding the boundary and bars lifting as it passes.

socialfriendlyfuturisticinteraction · finite · advanced · ~12.0s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

403 lines · react + motion only
import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from "react";
import {
  animate,
  motion,
  useMotionValue,
  useMotionValueEvent,
  useReducedMotion,
  useTransform,
  type MotionValue,
} from "motion/react";

/**
 * Vibary · Voice Message Play
 *
 * The waveform fills left to right as the clip plays, a head rides the
 * boundary, and each bar lifts as the head passes it — every frame a
 * projection of one playback value rather than a canned loop.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the bubble reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `durationSeconds`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type VoiceMessagePlayProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Clip length in seconds — drive it from your own audio element. */
  durationSeconds?: number;
  /** Notified when playback starts and stops. */
  onPlayingChange?: (playing: boolean) => void;
  sender?: string;
  initials?: string;
  timestamp?: string;
  /** Played-through color. A state color, so it stays literal. */
  accent?: string;
};

type VariantConfig = {
  /** Height multiplier for a bar as the head passes it. 1 is no lift. */
  liftScale: number;
  /** Width of the lift, as a share of the clip. */
  reach: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: springs at or above a 0.8 damping ratio
// (damping / 2√stiffness). The only spring here is the play/pause glyph;
// the waveform is driven directly by playback position, because a clip's
// progress is a fact, not an easing curve. Variants differ in how alive
// the bars are as the head passes.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A flat sweep — the bar under the head barely rises. For a
  // transcript-first interface where the audio is secondary.
  subtle: {
    liftScale: 1.06,
    reach: 0.04,
    spring: { type: "spring", stiffness: 620, damping: 46 },
  },
  // The bar under the head breathes. All-purpose.
  default: {
    liftScale: 1.28,
    reach: 0.07,
    spring: { type: "spring", stiffness: 500, damping: 40 },
  },
  // A pronounced ripple travelling with the head, over a wider stretch
  // of the wave, for a voice-first app.
  playful: {
    liftScale: 1.62,
    reach: 0.12,
    spring: { type: "spring", stiffness: 400, damping: 34 },
  },
};

const ACCENT = "#4C7DF0";

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` yields surfaces and borders correctly toned on a light
 *  page and on a dark one. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

/** A recorded envelope, not random: the same shape every render, on the
 *  server and on the client. Swap in your own peaks from decoded audio. */
const PEAKS = [
  0.22, 0.36, 0.54, 0.42, 0.68, 0.86, 0.72, 0.5, 0.63, 0.9, 0.78, 0.44, 0.3,
  0.52, 0.74, 0.95, 0.82, 0.58, 0.4, 0.66, 0.88, 0.7, 0.46, 0.34, 0.5, 0.26,
];
const BAR_WIDTH = 3;
const BAR_GAP = 3;
const BAR_MAX = 26;
const WAVE_WIDTH = PEAKS.length * BAR_WIDTH + (PEAKS.length - 1) * BAR_GAP;

function formatTime(totalSeconds: number) {
  const minutes = Math.floor(totalSeconds / 60);
  const seconds = Math.floor(totalSeconds % 60);
  return `${minutes}:${String(seconds).padStart(2, "0")}`;
}

/** One bar. It reads the shared playback value and lifts as the head
 *  crosses it, so the wave is alive exactly where the sound is. */
function WaveBar({
  progress,
  center,
  peak,
  color,
  liftScale,
  reach,
  live,
}: {
  progress: MotionValue<number>;
  center: number;
  peak: number;
  color: string;
  liftScale: number;
  reach: number;
  live: boolean;
}) {
  const scaleY = useTransform(
    progress,
    [center - reach, center, center + reach],
    [1, liftScale, 1]
  );
  return (
    <motion.span
      style={{
        width: BAR_WIDTH,
        height: Math.max(4, Math.round(peak * BAR_MAX)),
        borderRadius: BAR_WIDTH,
        background: color,
        scaleY: live ? scaleY : 1,
      }}
    />
  );
}

export default function VoiceMessagePlay({
  variant = "default",
  durationSeconds = 12,
  onPlayingChange,
  sender = "Priya Raman",
  initials = "PR",
  timestamp = "9:41",
  accent = ACCENT,
}: VoiceMessagePlayProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const progress = useMotionValue(0);
  const [playing, setPlaying] = useState(false);
  const [elapsed, setElapsed] = useState(0);
  const playback = useRef<{ stop: () => void } | null>(null);

  // Played share of the wave, as a width — one clip over the accent copy
  // of the bars keeps both layers pixel-aligned with no per-bar color work.
  const playedWidth = useTransform(progress, [0, 1], ["0%", "100%"]);
  const headX = useTransform(progress, [0, 1], [0, WAVE_WIDTH]);
  const headOpacity = useTransform(progress, [0, 0.01], [0, 1]);

  useMotionValueEvent(progress, "change", (value) => {
    const seconds = Math.min(durationSeconds, value * durationSeconds);
    setElapsed((current) =>
      Math.floor(current) === Math.floor(seconds) ? current : seconds
    );
  });

  useEffect(() => () => playback.current?.stop(), []);

  const start = (from: number) => {
    playback.current?.stop();
    progress.set(from);
    setPlaying(true);
    onPlayingChange?.(true);
    playback.current = animate(progress, 1, {
      duration: durationSeconds * (1 - from),
      ease: "linear",
      onComplete: () => {
        setPlaying(false);
        onPlayingChange?.(false);
      },
    });
  };

  const toggle = () => {
    if (playing) {
      playback.current?.stop();
      setPlaying(false);
      onPlayingChange?.(false);
      return;
    }
    start(progress.get() >= 1 ? 0 : progress.get());
  };

  // Scrubbing is the same value from the other direction: set it, and the
  // fill, the head and the bar lifts all follow on the next frame.
  const seekTo = (ratio: number) => {
    const clamped = Math.min(1, Math.max(0, ratio));
    if (playing) start(clamped);
    else {
      progress.set(clamped);
      setElapsed(clamped * durationSeconds);
    }
  };

  const seekFromPointer = (event: MouseEvent<HTMLDivElement>) => {
    const bounds = event.currentTarget.getBoundingClientRect();
    seekTo((event.clientX - bounds.left) / bounds.width);
  };

  const seekFromKey = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
    event.preventDefault();
    seekTo(progress.get() + (event.key === "ArrowRight" ? 0.05 : -0.05));
  };

  const bars = (color: string) =>
    PEAKS.map((peak, index) => (
      <WaveBar
        key={index}
        progress={progress}
        center={(index + 0.5) / PEAKS.length}
        peak={peak}
        color={color}
        liftScale={cfg.liftScale}
        reach={cfg.reach}
        // Reduced motion: the fill still tracks playback — that is the
        // information — but the bars stop rippling under the head.
        live={!reduceMotion && playing}
      />
    ));

  return (
    <div
      style={{
        display: "flex",
        alignItems: "flex-end",
        gap: 9,
        width: 320,
      }}
    >
      <span
        aria-hidden
        style={{
          width: 28,
          height: 28,
          flexShrink: 0,
          borderRadius: "50%",
          display: "grid",
          placeItems: "center",
          fontSize: 11,
          fontWeight: 600,
          color: "#fff",
          background: "linear-gradient(140deg,#4C7DF0,#7C5AE8)",
        }}
      >
        {initials}
      </span>

      <div
        style={{
          display: "flex",
          alignItems: "center",
          gap: 11,
          padding: "10px 13px 10px 10px",
          borderRadius: 18,
          borderBottomLeftRadius: 6,
          background: tone(8),
        }}
      >
        <button
          type="button"
          onClick={toggle}
          aria-label={playing ? `Pause message from ${sender}` : `Play message from ${sender}`}
          style={{
            position: "relative",
            width: 34,
            height: 34,
            flexShrink: 0,
            padding: 0,
            borderRadius: "50%",
            border: 0,
            background: accent,
            color: "#fff",
            cursor: "pointer",
          }}
        >
          <motion.span
            aria-hidden
            initial={false}
            animate={{ opacity: playing ? 0 : 1, scale: playing ? 0.85 : 1 }}
            transition={reduceMotion ? { duration: 0 } : cfg.spring}
            style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center" }}
          >
            <svg width="13" height="14" viewBox="0 0 14 16" aria-hidden>
              <path d="M2.5 1.6 12.4 8 2.5 14.4Z" fill="currentColor" />
            </svg>
          </motion.span>
          <motion.span
            aria-hidden
            initial={false}
            animate={{ opacity: playing ? 1 : 0, scale: playing ? 1 : 0.85 }}
            transition={reduceMotion ? { duration: 0 } : cfg.spring}
            style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center" }}
          >
            <svg width="12" height="14" viewBox="0 0 12 14" aria-hidden>
              <rect x="1" y="1" width="3.4" height="12" rx="1.5" fill="currentColor" />
              <rect x="7.6" y="1" width="3.4" height="12" rx="1.5" fill="currentColor" />
            </svg>
          </motion.span>
        </button>

        <div
          role="slider"
          tabIndex={0}
          aria-label="Playback position"
          aria-valuemin={0}
          aria-valuemax={durationSeconds}
          aria-valuenow={Math.round(elapsed)}
          aria-valuetext={`${formatTime(elapsed)} of ${formatTime(durationSeconds)}`}
          onClick={seekFromPointer}
          onKeyDown={seekFromKey}
          style={{
            position: "relative",
            width: WAVE_WIDTH,
            height: BAR_MAX + 4,
            cursor: "pointer",
            outlineOffset: 3,
          }}
        >
          <div
            style={{
              position: "absolute",
              inset: 0,
              display: "flex",
              alignItems: "center",
              gap: BAR_GAP,
            }}
          >
            {bars(tone(26))}
          </div>
          {/* The played copy of the same bars, revealed by a single clip
              whose width is the playback value. */}
          <motion.div
            aria-hidden
            style={{
              position: "absolute",
              inset: 0,
              overflow: "hidden",
              width: playedWidth,
            }}
          >
            <div
              style={{
                position: "absolute",
                inset: 0,
                width: WAVE_WIDTH,
                display: "flex",
                alignItems: "center",
                gap: BAR_GAP,
              }}
            >
              {bars(accent)}
            </div>
          </motion.div>
          <motion.span
            aria-hidden
            style={{
              position: "absolute",
              top: 0,
              left: -1,
              width: 2,
              height: "100%",
              borderRadius: 2,
              background: accent,
              x: headX,
              opacity: headOpacity,
            }}
          />
        </div>

        {/* Fixed slot, tabular figures: the clock changes value without
            changing the width of anything around it. */}
        <span
          style={{
            width: 30,
            flexShrink: 0,
            fontSize: 11,
            fontVariantNumeric: "tabular-nums",
            opacity: 0.55,
            textAlign: "right",
          }}
        >
          {formatTime(playing || elapsed > 0 ? elapsed : durationSeconds)}
          {/* Idle shows the clip length; once it has been touched, the
              clock shows how far in the reader is. */}
        </span>
      </div>

      <span style={{ fontSize: 10.5, opacity: 0.4, flexShrink: 0 }}>{timestamp}</span>
    </div>
  );
}

About this pattern

A voice note has no thumbnail, so the waveform has to do the work of showing both what is inside the clip and how far through it you are. Everything visible is a projection of one playback value: the played copy of the bars is revealed by a single clip whose width is that value, the head sits at its edge, and each bar lifts as the head crosses it, which makes the wave feel driven by sound rather than by a timer. Because the value is the source of truth in both directions, scrubbing is the same code read backwards — set the position and the fill, the head and the ripple all follow on the next frame. The clock sits in a fixed slot with tabular figures so counting up never nudges the bubble.

Voice note in a chatAudio clip previewPodcast snippetVoicemail playback

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • 10:15
    Dana Whitfieldonline
    Morning — did the venue confirm?
    They did, contract came back signed.10:14
    Are we still on for Thursday?
    Yes — booked the room for 2pm.10:14
    Perfect. I'll bring the printouts.
    See you then.10:14
    Message
    Chat thread

    The voice note's bars tint as playback passes them, with a draggable head at the boundary.

Related patterns