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.
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.
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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Chat thread
The voice note's bars tint as playback passes them, with a draggable head at the boundary.
Related patterns
- Message Reaction AttachThe chosen glyph flies out of the picker and docks at the corner of the bubble it belongs to.
- Message SendThe composed line lifts out of the input and lands as the newest bubble.
- Swipe to ReplyDragging a message uncovers a reply mark that grows with the pull, snaps once at the threshold, and springs back.