Media Waveform
Original · freeaudio waveform scrubber
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/media-waveform.json"use client";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
type Marker = {
at: number;
label: string;
};
const DURATION = 214;
const MARKERS: Marker[] = [
{ at: 0, label: "Cold open" },
{ at: 38, label: "Guest intro" },
{ at: 96, label: "The pricing mistake" },
{ at: 158, label: "Listener question" },
{ at: 194, label: "Outro" },
];
const BAR_COUNT = 96;
function buildPeaks(count: number): number[] {
const peaks: number[] = [];
for (let i = 0; i < count; i += 1) {
const t = i / (count - 1);
const speech = Math.sin(t * 41.3) * 0.5 + Math.sin(t * 17.7 + 1.4) * 0.32;
const breath = Math.sin(t * 6.1 + 0.6) * 0.22;
const envelope = 0.42 + 0.58 * Math.sin(Math.PI * Math.min(1, t * 1.06));
const gap = t > 0.34 && t < 0.365 ? 0.12 : 1;
const raw = Math.abs(speech + breath) * envelope * gap;
peaks.push(Math.max(0.08, Math.min(1, raw * 1.15 + 0.1)));
}
return peaks;
}
function formatTime(seconds: number): string {
const safe = Math.max(0, Math.floor(seconds));
const m = Math.floor(safe / 60);
const s = safe % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
export default function MediaWaveform() {
const reduceMotion = useReducedMotion();
const headingId = useId();
const statusId = useId();
const peaks = useMemo(() => buildPeaks(BAR_COUNT), []);
const [time, setTime] = useState(0);
const [playing, setPlaying] = useState(false);
const [scrubbing, setScrubbing] = useState(false);
const [hoverRatio, setHoverRatio] = useState<number | null>(null);
const [rate, setRate] = useState(1);
const [muted, setMuted] = useState(false);
const trackRef = useRef<HTMLDivElement | null>(null);
const rafRef = useRef<number | null>(null);
const lastTickRef = useRef<number>(0);
const rateRef = useRef(rate);
rateRef.current = rate;
useEffect(() => {
if (!playing) {
lastTickRef.current = 0;
return;
}
const step = (now: number) => {
if (lastTickRef.current === 0) lastTickRef.current = now;
const delta = (now - lastTickRef.current) / 1000;
lastTickRef.current = now;
setTime((prev) => {
const next = prev + delta * rateRef.current;
if (next >= DURATION) {
setPlaying(false);
return DURATION;
}
return next;
});
rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
return () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
rafRef.current = null;
};
}, [playing]);
const ratioFromClientX = useCallback((clientX: number): number => {
const el = trackRef.current;
if (!el) return 0;
const rect = el.getBoundingClientRect();
if (rect.width === 0) return 0;
return Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
}, []);
const seekTo = useCallback((seconds: number) => {
setTime(Math.min(DURATION, Math.max(0, seconds)));
}, []);
useEffect(() => {
if (!scrubbing) return;
const onMove = (e: PointerEvent) => {
seekTo(ratioFromClientX(e.clientX) * DURATION);
};
const onUp = () => setScrubbing(false);
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onUp);
return () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onUp);
};
}, [scrubbing, ratioFromClientX, seekTo]);
const onKeyDown = useCallback(
(e: ReactKeyboardEvent<HTMLDivElement>) => {
let handled = true;
switch (e.key) {
case "ArrowRight":
seekTo(time + (e.shiftKey ? 15 : 5));
break;
case "ArrowLeft":
seekTo(time - (e.shiftKey ? 15 : 5));
break;
case "ArrowUp":
seekTo(time + 5);
break;
case "ArrowDown":
seekTo(time - 5);
break;
case "Home":
seekTo(0);
break;
case "End":
seekTo(DURATION);
break;
case "PageUp":
seekTo(time + 30);
break;
case "PageDown":
seekTo(time - 30);
break;
case " ":
case "Enter":
setPlaying((p) => !p);
break;
default:
handled = false;
}
if (handled) e.preventDefault();
},
[time, seekTo],
);
const progress = time / DURATION;
const activeMarker = useMemo(() => {
let current = MARKERS[0];
for (const m of MARKERS) if (time >= m.at) current = m;
return current;
}, [time]);
const hoverSeconds = hoverRatio === null ? null : hoverRatio * DURATION;
return (
<section
aria-labelledby={headingId}
className="relative w-full bg-slate-50 px-4 py-20 text-slate-900 sm:px-6 sm:py-24 dark:bg-slate-950 dark:text-slate-100"
>
<style>{`
@keyframes mwfm-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(1.9); }
}
@keyframes mwfm-drift {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
.mwfm-live-dot { animation: mwfm-pulse 1.8s ease-in-out infinite; }
.mwfm-sheen {
background-image: linear-gradient(100deg, transparent 20%, rgba(255,255,255,0.55) 50%, transparent 80%);
background-size: 200% 100%;
animation: mwfm-drift 3.4s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.mwfm-live-dot, .mwfm-sheen { animation: none !important; }
}
`}</style>
<div className="mx-auto w-full max-w-3xl">
<header className="mb-8 flex flex-wrap items-end justify-between gap-4">
<div>
<p className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
<span
aria-hidden="true"
className={`inline-block h-1.5 w-1.5 rounded-full bg-indigo-500 ${
playing && !reduceMotion ? "mwfm-live-dot" : ""
}`}
/>
Episode 41 · Margins
</p>
<h2 id={headingId} className="text-2xl font-semibold tracking-tight sm:text-3xl">
Why your agency retainer keeps shrinking
</h2>
<p className="mt-2 text-sm text-slate-600 dark:text-slate-400">
Recorded 12 March · 3 min 34 sec · with Priya Raman
</p>
</div>
<div className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-right dark:border-slate-800 dark:bg-slate-900">
<div className="font-mono text-lg tabular-nums text-slate-900 dark:text-slate-100">
{formatTime(time)}
</div>
<div className="font-mono text-xs tabular-nums text-slate-500 dark:text-slate-500">
−{formatTime(DURATION - time)}
</div>
</div>
</header>
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6 dark:border-slate-800 dark:bg-slate-900">
<div
ref={trackRef}
role="slider"
tabIndex={0}
aria-label="Audio position"
aria-valuemin={0}
aria-valuemax={DURATION}
aria-valuenow={Math.round(time)}
aria-valuetext={`${formatTime(time)} of ${formatTime(DURATION)}, ${activeMarker.label}`}
aria-describedby={statusId}
onKeyDown={onKeyDown}
onPointerDown={(e) => {
e.currentTarget.focus();
setScrubbing(true);
seekTo(ratioFromClientX(e.clientX) * DURATION);
}}
onPointerMove={(e) => setHoverRatio(ratioFromClientX(e.clientX))}
onPointerLeave={() => setHoverRatio(null)}
className={`group relative h-28 w-full cursor-pointer touch-none select-none rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white sm:h-32 dark:focus-visible:ring-offset-slate-900 ${
scrubbing ? "cursor-grabbing" : ""
}`}
>
<div className="flex h-full w-full items-center gap-[2px]" aria-hidden="true">
{peaks.map((peak, i) => {
const barRatio = (i + 0.5) / BAR_COUNT;
const played = barRatio <= progress;
const nearHover =
hoverRatio !== null && Math.abs(barRatio - hoverRatio) < 0.02 && !scrubbing;
return (
<motion.span
key={i}
className={`flex-1 rounded-full ${
played
? "bg-indigo-500 dark:bg-indigo-400"
: "bg-slate-300 dark:bg-slate-700"
}`}
style={{ height: `${peak * 100}%`, transformOrigin: "center" }}
animate={
reduceMotion
? undefined
: { scaleY: nearHover ? 1.14 : 1, opacity: played ? 1 : 0.85 }
}
transition={{ type: "spring", stiffness: 420, damping: 30 }}
/>
);
})}
</div>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 w-px bg-slate-900 dark:bg-white"
style={{ left: `${progress * 100}%` }}
>
<span className="absolute -top-1 left-1/2 h-2.5 w-2.5 -translate-x-1/2 rounded-full bg-slate-900 ring-2 ring-white dark:bg-white dark:ring-slate-900" />
<span className="absolute -bottom-1 left-1/2 h-2.5 w-2.5 -translate-x-1/2 rounded-full bg-slate-900 ring-2 ring-white dark:bg-white dark:ring-slate-900" />
</div>
{hoverSeconds !== null ? (
<div
aria-hidden="true"
className="pointer-events-none absolute -top-8 z-10 -translate-x-1/2 rounded-md bg-slate-900 px-2 py-1 font-mono text-[11px] tabular-nums text-white shadow-md dark:bg-white dark:text-slate-900"
style={{ left: `${(hoverRatio ?? 0) * 100}%` }}
>
{formatTime(hoverSeconds)}
</div>
) : null}
</div>
<div className="relative mt-3 h-5" aria-hidden="true">
{MARKERS.map((m) => (
<span
key={m.at}
className="absolute top-0 -translate-x-1/2 text-[10px] font-medium text-slate-400 dark:text-slate-600"
style={{ left: `${Math.min(97, Math.max(3, (m.at / DURATION) * 100))}%` }}
>
{formatTime(m.at)}
</span>
))}
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-4 border-t border-slate-200 pt-4 dark:border-slate-800">
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => seekTo(time - 15)}
aria-label="Rewind 15 seconds"
className="rounded-lg p-2 text-slate-600 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-400 dark:hover:bg-slate-800"
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M11 5 4 12l7 7" />
<path d="M20 5l-7 7 7 7" />
</svg>
</button>
<button
type="button"
onClick={() => setPlaying((p) => !p)}
aria-label={playing ? "Pause" : "Play"}
aria-pressed={playing}
className="relative overflow-hidden rounded-full bg-slate-900 p-3 text-white transition hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-offset-slate-900"
>
{playing && !reduceMotion ? (
<span aria-hidden="true" className="mwfm-sheen pointer-events-none absolute inset-0 opacity-30" />
) : null}
{playing ? (
<svg viewBox="0 0 24 24" className="relative h-5 w-5" fill="currentColor" aria-hidden="true">
<rect x="6" y="5" width="4" height="14" rx="1" />
<rect x="14" y="5" width="4" height="14" rx="1" />
</svg>
) : (
<svg viewBox="0 0 24 24" className="relative h-5 w-5" fill="currentColor" aria-hidden="true">
<path d="M8 5.14v13.72a1 1 0 0 0 1.54.84l10.3-6.86a1 1 0 0 0 0-1.68L9.54 4.3A1 1 0 0 0 8 5.14Z" />
</svg>
)}
</button>
<button
type="button"
onClick={() => seekTo(time + 15)}
aria-label="Forward 15 seconds"
className="rounded-lg p-2 text-slate-600 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-400 dark:hover:bg-slate-800"
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M13 5l7 7-7 7" />
<path d="M4 5l7 7-7 7" />
</svg>
</button>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setRate((r) => (r === 1 ? 1.25 : r === 1.25 ? 1.5 : r === 1.5 ? 2 : 1))}
aria-label={`Playback speed, currently ${rate} times. Activate to change.`}
className="rounded-lg border border-slate-200 px-2.5 py-1.5 font-mono text-xs tabular-nums text-slate-700 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
>
{rate}×
</button>
<button
type="button"
onClick={() => setMuted((m) => !m)}
aria-label={muted ? "Unmute" : "Mute"}
aria-pressed={muted}
className="rounded-lg p-2 text-slate-600 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-400 dark:hover:bg-slate-800"
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M11 5 6 9H3v6h3l5 4V5Z" />
{muted ? (
<>
<path d="m22 9-6 6" />
<path d="m16 9 6 6" />
</>
) : (
<>
<path d="M15.5 8.5a5 5 0 0 1 0 7" />
<path d="M18.5 5.5a9 9 0 0 1 0 13" />
</>
)}
</svg>
</button>
</div>
</div>
</div>
<div className="mt-6">
<h3 className="mb-3 text-xs font-medium uppercase tracking-[0.14em] text-slate-500 dark:text-slate-500">
Chapters
</h3>
<ul className="grid gap-1.5 sm:grid-cols-2">
{MARKERS.map((m) => {
const isActive = activeMarker.at === m.at;
return (
<li key={m.at}>
<button
type="button"
onClick={() => seekTo(m.at)}
aria-current={isActive ? "true" : undefined}
className={`flex w-full items-center justify-between gap-3 rounded-lg border px-3 py-2 text-left text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 ${
isActive
? "border-indigo-300 bg-indigo-50 text-indigo-900 dark:border-indigo-500/40 dark:bg-indigo-500/10 dark:text-indigo-200"
: "border-slate-200 bg-white text-slate-700 hover:border-slate-300 hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
}`}
>
<span className="truncate">{m.label}</span>
<span className="shrink-0 font-mono text-xs tabular-nums opacity-60">
{formatTime(m.at)}
</span>
</button>
</li>
);
})}
</ul>
</div>
<p id={statusId} aria-live="polite" className="sr-only">
{playing ? "Playing" : "Paused"} at {formatTime(time)}, chapter {activeMarker.label}, speed{" "}
{rate} times, {muted ? "muted" : "sound on"}.
</p>
<p className="mt-6 text-xs text-slate-500 dark:text-slate-500">
Drag the waveform to scrub. With it focused: arrows seek 5s (hold Shift for 15s), Page
Up/Down jump 30s, Home and End go to the ends, Space toggles playback.
</p>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Media Video Player
Originalcustom video player controls UI

Media Audio Player
Originalaudio player with progress and volume

Media Podcast
Originalpodcast episode player card

Media Playlist
Originalmusic playlist with now-playing

Media Mini Player
Originalcompact sticky mini player

Media Video Card
Originalvideo thumbnail card with play overlay

Media Video Hero
Originalhero with a video-style backdrop

Media Gallery Player
Originalmedia gallery with a lightbox player

Media Live Badge
Originallive stream card with viewer count

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

