Media Audio Player
Original · freeaudio player with progress and volume
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-audio-player.json"use client";
import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
type Track = {
id: string;
title: string;
artist: string;
album: string;
duration: number;
waveform: number[];
};
const TRACKS: Track[] = [
{
id: "t1",
title: "Harbour Lights at 4am",
artist: "Nils Okonkwo",
album: "Slow Tide",
duration: 227,
waveform: [
0.18, 0.24, 0.31, 0.27, 0.42, 0.55, 0.49, 0.63, 0.71, 0.58, 0.66, 0.82,
0.74, 0.61, 0.53, 0.68, 0.79, 0.91, 0.84, 0.72, 0.65, 0.57, 0.44, 0.51,
0.62, 0.77, 0.88, 0.95, 0.86, 0.73, 0.64, 0.55, 0.47, 0.39, 0.52, 0.66,
0.78, 0.69, 0.58, 0.46, 0.37, 0.29, 0.35, 0.48, 0.61, 0.7, 0.59, 0.44,
0.33, 0.26, 0.21, 0.16,
],
},
{
id: "t2",
title: "Copper Wire Telegram",
artist: "The Meridian Set",
album: "Field Notes, Vol. 2",
duration: 194,
waveform: [
0.42, 0.51, 0.63, 0.58, 0.47, 0.39, 0.55, 0.72, 0.81, 0.69, 0.6, 0.74,
0.88, 0.93, 0.79, 0.66, 0.54, 0.61, 0.7, 0.83, 0.76, 0.64, 0.52, 0.43,
0.36, 0.45, 0.59, 0.68, 0.77, 0.85, 0.9, 0.81, 0.7, 0.62, 0.5, 0.41,
0.34, 0.28, 0.4, 0.53, 0.67, 0.75, 0.66, 0.54, 0.42, 0.35, 0.3, 0.24,
0.19, 0.15, 0.12, 0.1,
],
},
{
id: "t3",
title: "Everything Sounds Like Rain",
artist: "Petra Vasquez",
album: "Room Tone",
duration: 312,
waveform: [
0.12, 0.15, 0.2, 0.26, 0.34, 0.29, 0.38, 0.46, 0.41, 0.5, 0.58, 0.67,
0.61, 0.53, 0.45, 0.56, 0.68, 0.75, 0.7, 0.62, 0.71, 0.83, 0.92, 0.87,
0.78, 0.69, 0.6, 0.51, 0.43, 0.36, 0.48, 0.6, 0.72, 0.8, 0.89, 0.94,
0.85, 0.74, 0.63, 0.52, 0.44, 0.37, 0.31, 0.4, 0.5, 0.59, 0.48, 0.38,
0.3, 0.23, 0.18, 0.13,
],
},
];
const SPEEDS = [0.75, 1, 1.25, 1.5, 2] as const;
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")}`;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
export default function MediaAudioPlayer() {
const reduceMotion = useReducedMotion();
const [trackIndex, setTrackIndex] = useState<number>(0);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [elapsed, setElapsed] = useState<number>(0);
const [volume, setVolume] = useState<number>(70);
const [lastVolume, setLastVolume] = useState<number>(70);
const [speedIndex, setSpeedIndex] = useState<number>(1);
const [shuffle, setShuffle] = useState<boolean>(false);
const [scrubbing, setScrubbing] = useState<boolean>(false);
const rafRef = useRef<number | null>(null);
const lastTickRef = useRef<number>(0);
const seekBarRef = useRef<HTMLDivElement | null>(null);
const track = TRACKS[trackIndex];
const speed = SPEEDS[speedIndex];
const progress = track.duration > 0 ? elapsed / track.duration : 0;
const muted = volume === 0;
const goToTrack = useCallback((next: number) => {
const total = TRACKS.length;
setTrackIndex(((next % total) + total) % total);
setElapsed(0);
}, []);
const nextTrack = useCallback(() => {
if (shuffle && TRACKS.length > 1) {
let candidate = trackIndex;
while (candidate === trackIndex) {
candidate = Math.floor(Math.random() * TRACKS.length);
}
goToTrack(candidate);
return;
}
goToTrack(trackIndex + 1);
}, [goToTrack, shuffle, trackIndex]);
const prevTrack = useCallback(() => {
if (elapsed > 3) {
setElapsed(0);
return;
}
goToTrack(trackIndex - 1);
}, [elapsed, goToTrack, trackIndex]);
useEffect(() => {
if (!isPlaying || scrubbing) {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
return;
}
lastTickRef.current = performance.now();
const tick = (now: number) => {
const delta = (now - lastTickRef.current) / 1000;
lastTickRef.current = now;
setElapsed((current) => {
const advanced = current + delta * speed;
if (advanced >= track.duration) {
return track.duration;
}
return advanced;
});
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
}, [isPlaying, scrubbing, speed, track.duration]);
useEffect(() => {
if (elapsed >= track.duration && track.duration > 0) {
nextTrack();
}
}, [elapsed, nextTrack, track.duration]);
const seekFromClientX = useCallback(
(clientX: number) => {
const el = seekBarRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
if (rect.width === 0) return;
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);
setElapsed(ratio * track.duration);
},
[track.duration],
);
const handleSeekPointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
event.currentTarget.setPointerCapture(event.pointerId);
setScrubbing(true);
seekFromClientX(event.clientX);
},
[seekFromClientX],
);
const handleSeekPointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!scrubbing) return;
seekFromClientX(event.clientX);
},
[scrubbing, seekFromClientX],
);
const handleSeekPointerUp = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
setScrubbing(false);
lastTickRef.current = performance.now();
},
[],
);
const handleSeekKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLDivElement>) => {
const step = event.shiftKey ? 30 : 5;
let handled = true;
switch (event.key) {
case "ArrowRight":
case "ArrowUp":
setElapsed((c) => clamp(c + step, 0, track.duration));
break;
case "ArrowLeft":
case "ArrowDown":
setElapsed((c) => clamp(c - step, 0, track.duration));
break;
case "Home":
setElapsed(0);
break;
case "End":
setElapsed(track.duration);
break;
case " ":
case "Enter":
setIsPlaying((p) => !p);
break;
default:
handled = false;
}
if (handled) event.preventDefault();
},
[track.duration],
);
const toggleMute = useCallback(() => {
if (volume === 0) {
setVolume(lastVolume === 0 ? 60 : lastVolume);
} else {
setLastVolume(volume);
setVolume(0);
}
}, [lastVolume, volume]);
const barCount = track.waveform.length;
const activeBars = useMemo(
() => Math.round(progress * barCount),
[barCount, progress],
);
const remaining = track.duration - elapsed;
return (
<section className="relative w-full bg-slate-50 px-4 py-20 sm:px-6 sm:py-24 dark:bg-slate-950">
<style>{`
@keyframes mapx-pulse-ring {
0% { transform: scale(1); opacity: 0.45; }
70% { transform: scale(1.7); opacity: 0; }
100% { transform: scale(1.7); opacity: 0; }
}
@keyframes mapx-eq-bounce {
0%, 100% { transform: scaleY(0.35); }
50% { transform: scaleY(1); }
}
@keyframes mapx-sheen-drift {
0% { transform: translate3d(-12%, 0, 0) rotate(0deg); }
50% { transform: translate3d(12%, -6%, 0) rotate(6deg); }
100% { transform: translate3d(-12%, 0, 0) rotate(0deg); }
}
.mapx-ring { animation: mapx-pulse-ring 2.4s ease-out infinite; }
.mapx-eq-bar { animation: mapx-eq-bounce 900ms ease-in-out infinite; transform-origin: bottom; }
.mapx-sheen { animation: mapx-sheen-drift 14s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.mapx-ring, .mapx-eq-bar, .mapx-sheen {
animation: none !important;
}
}
.mapx-volume::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
height: 14px;
width: 14px;
border-radius: 9999px;
background: #ffffff;
border: 2px solid rgb(79 70 229);
box-shadow: 0 1px 3px rgb(15 23 42 / 0.3);
cursor: pointer;
}
.mapx-volume::-moz-range-thumb {
height: 14px;
width: 14px;
border-radius: 9999px;
background: #ffffff;
border: 2px solid rgb(79 70 229);
box-shadow: 0 1px 3px rgb(15 23 42 / 0.3);
cursor: pointer;
}
.mapx-volume::-moz-range-progress {
height: 6px;
border-radius: 9999px;
background: rgb(99 102 241);
}
@media (prefers-color-scheme: dark) {
.mapx-volume::-webkit-slider-thumb {
background: rgb(15 23 42);
border-color: rgb(129 140 248);
}
.mapx-volume::-moz-range-thumb {
background: rgb(15 23 42);
border-color: rgb(129 140 248);
}
}
`}</style>
<div className="mx-auto w-full max-w-2xl">
<header className="mb-8 text-center">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
Now playing
</p>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-50">
Slow Tide — the late shift mix
</h2>
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600 dark:text-slate-400">
Three tracks recorded between midnight and dawn. Scrub with the
waveform, or use arrow keys — hold Shift to jump 30 seconds.
</p>
</header>
<div className="relative overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/40">
<div
aria-hidden="true"
className="mapx-sheen pointer-events-none absolute -top-24 -right-16 h-64 w-64 rounded-full bg-gradient-to-br from-indigo-400/25 via-violet-400/15 to-transparent blur-3xl dark:from-indigo-500/25 dark:via-violet-500/15"
/>
<div className="relative p-6 sm:p-8">
<div className="flex items-start gap-4 sm:gap-5">
<div className="relative shrink-0">
{isPlaying && !reduceMotion ? (
<span
aria-hidden="true"
className="mapx-ring absolute inset-0 rounded-2xl bg-indigo-500/40"
/>
) : null}
<div className="relative grid h-16 w-16 place-items-center rounded-2xl bg-gradient-to-br from-indigo-500 via-violet-500 to-indigo-600 text-white shadow-lg shadow-indigo-500/25 sm:h-20 sm:w-20">
{isPlaying ? (
<div
aria-hidden="true"
className="flex h-6 items-end gap-[3px]"
>
{[0, 1, 2, 3].map((i) => (
<span
key={i}
className="mapx-eq-bar block w-[3px] rounded-full bg-white/90"
style={{
height: `${12 + i * 4}px`,
animationDelay: `${i * 120}ms`,
}}
/>
))}
</div>
) : (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
className="h-7 w-7"
>
<path
d="M9 18V5l10-2v13"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle
cx="6"
cy="18"
r="3"
stroke="currentColor"
strokeWidth="1.75"
/>
<circle
cx="16"
cy="16"
r="3"
stroke="currentColor"
strokeWidth="1.75"
/>
</svg>
)}
</div>
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate text-lg font-semibold text-slate-900 sm:text-xl dark:text-slate-50">
{track.title}
</h3>
<p className="mt-0.5 truncate text-sm text-slate-600 dark:text-slate-400">
{track.artist}
</p>
<p className="mt-1 truncate text-xs text-slate-500 dark:text-slate-500">
{track.album} · Track {trackIndex + 1} of {TRACKS.length}
</p>
</div>
<button
type="button"
onClick={() => setShuffle((s) => !s)}
aria-pressed={shuffle}
className={`shrink-0 rounded-xl border p-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
shuffle
? "border-indigo-500/60 bg-indigo-50 text-indigo-600 dark:border-indigo-400/50 dark:bg-indigo-500/15 dark:text-indigo-300"
: "border-slate-200 text-slate-500 hover:bg-slate-100 hover:text-slate-700 dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
}`}
>
<span className="sr-only">
Shuffle {shuffle ? "on" : "off"}
</span>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
className="h-4 w-4"
>
<path
d="M16 3h5v5M4 20 21 3M21 16v5h-5M15 15l6 6M4 4l5 5"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
<div className="mt-6">
<div
ref={seekBarRef}
role="slider"
tabIndex={0}
aria-label="Seek"
aria-valuemin={0}
aria-valuemax={track.duration}
aria-valuenow={Math.floor(elapsed)}
aria-valuetext={`${formatTime(elapsed)} of ${formatTime(track.duration)}`}
onPointerDown={handleSeekPointerDown}
onPointerMove={handleSeekPointerMove}
onPointerUp={handleSeekPointerUp}
onPointerCancel={handleSeekPointerUp}
onKeyDown={handleSeekKeyDown}
className="group relative flex h-16 cursor-pointer touch-none items-end gap-[2px] rounded-xl px-1 py-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
>
{track.waveform.map((amp, i) => {
const played = i < activeBars;
return (
<motion.span
key={`${track.id}-${i}`}
aria-hidden="true"
className={`flex-1 rounded-full ${
played
? "bg-indigo-500 dark:bg-indigo-400"
: "bg-slate-200 group-hover:bg-slate-300 dark:bg-slate-700 dark:group-hover:bg-slate-600"
}`}
style={{ height: `${Math.max(8, amp * 100)}%` }}
initial={false}
animate={
reduceMotion
? { scaleY: 1 }
: {
scaleY:
isPlaying && played && i >= activeBars - 2
? 1.12
: 1,
}
}
transition={{ duration: 0.18, ease: "easeOut" }}
/>
);
})}
</div>
<div className="mt-1 flex items-center justify-between px-1 text-xs tabular-nums text-slate-500 dark:text-slate-500">
<span>{formatTime(elapsed)}</span>
<span>-{formatTime(remaining)}</span>
</div>
</div>
<div className="mt-5 flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center justify-center gap-2 sm:justify-start">
<button
type="button"
onClick={prevTrack}
className="rounded-full p-2.5 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-900"
>
<span className="sr-only">Previous track</span>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="currentColor"
className="h-5 w-5"
>
<path d="M6 5a1 1 0 0 1 2 0v5.6l9.5-5.4A1 1 0 0 1 19 6v12a1 1 0 0 1-1.5.8L8 13.4V19a1 1 0 1 1-2 0V5Z" />
</svg>
</button>
<motion.button
type="button"
onClick={() => setIsPlaying((p) => !p)}
aria-pressed={isPlaying}
whileTap={reduceMotion ? undefined : { scale: 0.94 }}
className="grid h-12 w-12 place-items-center rounded-full bg-slate-900 text-white shadow-lg shadow-slate-900/20 transition-colors hover:bg-slate-800 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-slate-50 dark:text-slate-900 dark:shadow-black/40 dark:hover:bg-white dark:focus-visible:ring-offset-slate-900"
>
<span className="sr-only">{isPlaying ? "Pause" : "Play"}</span>
{isPlaying ? (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="currentColor"
className="h-5 w-5"
>
<rect x="6" y="5" width="4" height="14" rx="1.25" />
<rect x="14" y="5" width="4" height="14" rx="1.25" />
</svg>
) : (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="currentColor"
className="ml-0.5 h-5 w-5"
>
<path d="M8 5.14v13.72a1 1 0 0 0 1.54.84l10.1-6.86a1 1 0 0 0 0-1.68L9.54 4.3A1 1 0 0 0 8 5.14Z" />
</svg>
)}
</motion.button>
<button
type="button"
onClick={nextTrack}
className="rounded-full p-2.5 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-900"
>
<span className="sr-only">Next track</span>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="currentColor"
className="h-5 w-5"
>
<path d="M18 5a1 1 0 0 0-2 0v5.6L6.5 5.2A1 1 0 0 0 5 6v12a1 1 0 0 0 1.5.8l9.5-5.4V19a1 1 0 1 0 2 0V5Z" />
</svg>
</button>
<button
type="button"
onClick={() => setSpeedIndex((i) => (i + 1) % SPEEDS.length)}
className="ml-1 rounded-full border border-slate-200 px-2.5 py-1 text-xs font-semibold tabular-nums text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-900"
>
<span className="sr-only">
Playback speed, currently {speed} times. Activate to change.
</span>
<span aria-hidden="true">{speed}×</span>
</button>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={toggleMute}
aria-pressed={muted}
className="rounded-lg p-1.5 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-900"
>
<span className="sr-only">{muted ? "Unmute" : "Mute"}</span>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
className="h-5 w-5"
>
<path
d="M11 5 6.5 9H3v6h3.5L11 19V5Z"
stroke="currentColor"
strokeWidth="1.75"
strokeLinejoin="round"
/>
{muted ? (
<path
d="m16 9.5 5 5m0-5-5 5"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
/>
) : (
<>
<path
d="M15 9.5a3.5 3.5 0 0 1 0 5"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
/>
{volume > 55 ? (
<path
d="M17.8 7a7 7 0 0 1 0 10"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
/>
) : null}
</>
)}
</svg>
</button>
<label className="flex flex-1 items-center gap-3 sm:flex-none">
<span className="sr-only">Volume</span>
<input
type="range"
min={0}
max={100}
step={1}
value={volume}
onChange={(e) => {
const v = Number(e.target.value);
setVolume(v);
if (v > 0) setLastVolume(v);
}}
aria-valuetext={`${volume} percent`}
className="mapx-volume h-1.5 w-full min-w-28 cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white sm:w-28 dark:bg-slate-700 dark:accent-indigo-400 dark:focus-visible:ring-offset-slate-900"
style={{
backgroundImage:
"linear-gradient(to right, rgb(99 102 241) 0%, rgb(99 102 241) " +
volume +
"%, transparent " +
volume +
"%, transparent 100%)",
}}
/>
<span className="w-8 shrink-0 text-right text-xs tabular-nums text-slate-500 dark:text-slate-500">
{volume}
</span>
</label>
</div>
</div>
</div>
<div className="border-t border-slate-200 bg-slate-50/70 px-3 py-3 dark:border-slate-800 dark:bg-slate-950/40">
<ul className="space-y-1">
{TRACKS.map((t, i) => {
const current = i === trackIndex;
return (
<li key={t.id}>
<button
type="button"
onClick={() => {
goToTrack(i);
setIsPlaying(true);
}}
aria-current={current ? "true" : undefined}
className={`flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950 ${
current
? "bg-white shadow-sm dark:bg-slate-800"
: "hover:bg-white/70 dark:hover:bg-slate-800/60"
}`}
>
<span
className={`w-4 shrink-0 text-center text-xs tabular-nums ${
current
? "text-indigo-600 dark:text-indigo-400"
: "text-slate-400 dark:text-slate-600"
}`}
aria-hidden="true"
>
{current && isPlaying ? "▶" : i + 1}
</span>
<span className="min-w-0 flex-1">
<span
className={`block truncate text-sm ${
current
? "font-semibold text-slate-900 dark:text-slate-50"
: "font-medium text-slate-700 dark:text-slate-300"
}`}
>
{t.title}
</span>
<span className="block truncate text-xs text-slate-500 dark:text-slate-500">
{t.artist}
</span>
</span>
<span className="shrink-0 text-xs tabular-nums text-slate-500 dark:text-slate-500">
{formatTime(t.duration)}
</span>
</button>
</li>
);
})}
</ul>
</div>
</div>
<p className="sr-only" role="status" aria-live="polite">
{isPlaying ? "Playing" : "Paused"} {track.title} by {track.artist} at{" "}
{speed} times speed. Volume {volume} percent.
</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 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 Waveform
Originalaudio waveform scrubber

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.

