Media Playlist
Original · freemusic playlist with now-playing
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-playlist.json"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Track = {
id: string;
title: string;
artist: string;
album: string;
year: number;
seconds: number;
hueFrom: string;
hueTo: string;
waveform: number[];
};
const TRACKS: Track[] = [
{
id: "t1",
title: "Static Bloom",
artist: "Neve Ardan",
album: "Long Weather",
year: 2023,
seconds: 224,
hueFrom: "#6366f1",
hueTo: "#a78bfa",
waveform: [
12, 18, 30, 44, 61, 52, 38, 47, 66, 78, 64, 49, 35, 41, 58, 72, 85, 70,
54, 40, 33, 45, 60, 74, 62, 48, 36, 28, 42, 57, 69, 55, 41, 30, 22, 16,
],
},
{
id: "t2",
title: "Harbour Lights, 4am",
artist: "The Quiet Ledger",
album: "Tidal Notes",
year: 2024,
seconds: 312,
hueFrom: "#0ea5e9",
hueTo: "#22d3ee",
waveform: [
8, 14, 22, 19, 27, 38, 51, 63, 55, 42, 31, 26, 34, 48, 62, 77, 88, 74,
59, 46, 37, 29, 24, 33, 45, 58, 71, 60, 47, 36, 27, 21, 17, 13, 10, 7,
],
},
{
id: "t3",
title: "Slow Cartography",
artist: "Ivo Mereaux",
album: "Paper Coastline",
year: 2022,
seconds: 197,
hueFrom: "#10b981",
hueTo: "#84cc16",
waveform: [
20, 33, 47, 58, 44, 32, 25, 39, 54, 68, 81, 66, 51, 43, 35, 48, 63, 79,
91, 76, 61, 50, 38, 30, 44, 59, 73, 57, 42, 34, 26, 19, 15, 24, 31, 18,
],
},
{
id: "t4",
title: "Ferrous",
artist: "Kaur & Dahl",
album: "Iron Season",
year: 2025,
seconds: 268,
hueFrom: "#f43f5e",
hueTo: "#f59e0b",
waveform: [
31, 42, 66, 84, 72, 55, 40, 51, 69, 87, 94, 78, 60, 46, 38, 52, 71, 89,
96, 80, 63, 49, 41, 55, 73, 90, 75, 58, 44, 36, 28, 35, 47, 30, 21, 14,
],
},
{
id: "t5",
title: "Understudy",
artist: "Neve Ardan",
album: "Long Weather",
year: 2023,
seconds: 176,
hueFrom: "#8b5cf6",
hueTo: "#f472b6",
waveform: [
9, 16, 25, 36, 29, 21, 15, 23, 34, 46, 39, 30, 24, 32, 44, 57, 68, 56,
45, 35, 27, 20, 28, 40, 53, 64, 52, 41, 32, 25, 18, 12, 9, 15, 22, 11,
],
},
{
id: "t6",
title: "Nightbus Interlude",
artist: "The Quiet Ledger",
album: "Tidal Notes",
year: 2024,
seconds: 143,
hueFrom: "#38bdf8",
hueTo: "#818cf8",
waveform: [
14, 21, 17, 26, 35, 48, 40, 31, 24, 33, 45, 59, 70, 58, 46, 37, 28, 22,
30, 42, 55, 67, 54, 43, 34, 26, 20, 15, 23, 32, 41, 29, 22, 16, 11, 8,
],
},
];
function fmt(total: number): string {
const m = Math.floor(total / 60);
const s = Math.floor(total % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
function fmtSpoken(total: number): string {
const m = Math.floor(total / 60);
const s = Math.floor(total % 60);
return `${m} minute${m === 1 ? "" : "s"} ${s} second${s === 1 ? "" : "s"}`;
}
export default function MediaPlaylist() {
const reduced = useReducedMotion();
const [index, setIndex] = useState<number>(0);
const [playing, setPlaying] = useState<boolean>(false);
const [elapsed, setElapsed] = useState<number>(0);
const [shuffle, setShuffle] = useState<boolean>(false);
const [repeat, setRepeat] = useState<boolean>(false);
const [volume, setVolume] = useState<number>(72);
const [muted, setMuted] = useState<boolean>(false);
const [liked, setLiked] = useState<Record<string, boolean>>({ t3: true });
const [scrubbing, setScrubbing] = useState<boolean>(false);
const rafRef = useRef<number | null>(null);
const lastTickRef = useRef<number>(0);
const listRef = useRef<HTMLUListElement | null>(null);
const track = TRACKS[index];
const duration = track.seconds;
const progress = Math.min(1, elapsed / duration);
const goTo = useCallback((next: number) => {
setIndex(((next % TRACKS.length) + TRACKS.length) % TRACKS.length);
setElapsed(0);
}, []);
const advance = useCallback(() => {
if (repeat) {
setElapsed(0);
return;
}
if (shuffle) {
setIndex((cur) => {
if (TRACKS.length < 2) return cur;
let next = cur;
while (next === cur) next = Math.floor(Math.random() * TRACKS.length);
return next;
});
setElapsed(0);
return;
}
setIndex((cur) => (cur + 1) % TRACKS.length);
setElapsed(0);
}, [repeat, shuffle]);
useEffect(() => {
if (!playing || scrubbing) {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
rafRef.current = null;
lastTickRef.current = 0;
return;
}
const step = (now: number) => {
if (lastTickRef.current === 0) lastTickRef.current = now;
const delta = (now - lastTickRef.current) / 1000;
lastTickRef.current = now;
setElapsed((prev) => {
const next = prev + delta;
if (next >= duration) {
advance();
return 0;
}
return next;
});
rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
return () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
rafRef.current = null;
lastTickRef.current = 0;
};
}, [playing, scrubbing, duration, advance]);
const totalRuntime = useMemo(
() => TRACKS.reduce((sum, t) => sum + t.seconds, 0),
[]
);
const onListKeyDown = (event: ReactKeyboardEvent<HTMLUListElement>) => {
const items = listRef.current?.querySelectorAll<HTMLButtonElement>(
"button[data-track-item]"
);
if (!items || items.length === 0) return;
const current = Array.from(items).findIndex(
(el) => el === document.activeElement
);
if (current === -1) return;
let target = -1;
if (event.key === "ArrowDown") target = (current + 1) % items.length;
else if (event.key === "ArrowUp")
target = (current - 1 + items.length) % items.length;
else if (event.key === "Home") target = 0;
else if (event.key === "End") target = items.length - 1;
if (target !== -1) {
event.preventDefault();
items[target].focus();
}
};
const bars = track.waveform;
const activeBar = Math.floor(progress * bars.length);
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-white to-slate-100 px-4 py-20 text-slate-900 sm:px-6 sm:py-24 dark:from-zinc-950 dark:via-zinc-950 dark:to-black dark:text-zinc-50">
<style>{`
@keyframes mplEqBounce {
0%, 100% { transform: scaleY(0.28); }
50% { transform: scaleY(1); }
}
@keyframes mplDiscSpin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes mplAuraDrift {
0% { transform: translate3d(-6%, -4%, 0) scale(1); }
50% { transform: translate3d(6%, 5%, 0) scale(1.12); }
100% { transform: translate3d(-6%, -4%, 0) scale(1); }
}
.mpl-eq-bar { transform-origin: bottom; animation: mplEqBounce 900ms ease-in-out infinite; }
.mpl-disc-spin { animation: mplDiscSpin 9s linear infinite; }
.mpl-aura { animation: mplAuraDrift 14s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.mpl-eq-bar, .mpl-disc-spin, .mpl-aura {
animation: none !important;
}
.mpl-eq-bar { transform: scaleY(0.6); }
}
`}</style>
<div
aria-hidden="true"
className="mpl-aura pointer-events-none absolute -top-40 left-1/2 h-[28rem] w-[28rem] -translate-x-1/2 rounded-full opacity-30 blur-3xl dark:opacity-25"
style={{
background: `radial-gradient(circle at 50% 50%, ${track.hueFrom}, transparent 68%)`,
}}
/>
<div className="relative mx-auto w-full max-w-5xl">
<header className="mb-10 flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-indigo-600 dark:text-indigo-400">
Curated set
</p>
<h2 className="mt-2 text-3xl font-semibold tracking-tight sm:text-4xl">
Rooms with the lights off
</h2>
<p className="mt-2 max-w-lg text-sm text-slate-600 dark:text-zinc-400">
Six tracks for late edits and long trains. {TRACKS.length} songs ·{" "}
{Math.round(totalRuntime / 60)} minutes.
</p>
</div>
<div className="flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1.5 text-xs font-medium text-slate-600 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-400">
<span
className={`h-1.5 w-1.5 rounded-full ${
playing ? "bg-emerald-500" : "bg-slate-400 dark:bg-zinc-600"
}`}
/>
{playing ? "Now playing" : "Paused"}
</div>
</header>
<div className="grid gap-6 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)]">
{/* Now playing card */}
<div className="relative overflow-hidden rounded-3xl border border-slate-200 bg-white/80 p-6 shadow-sm backdrop-blur-xl dark:border-zinc-800 dark:bg-zinc-900/60">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 -top-24 h-48 opacity-40 blur-2xl transition-opacity dark:opacity-30"
style={{
background: `linear-gradient(120deg, ${track.hueFrom}, ${track.hueTo})`,
}}
/>
<div className="relative mx-auto aspect-square w-full max-w-[15rem]">
<div
className="absolute inset-0 rounded-2xl shadow-lg"
style={{
background: `linear-gradient(140deg, ${track.hueFrom}, ${track.hueTo})`,
}}
/>
<div
aria-hidden="true"
className="absolute inset-0 rounded-2xl opacity-60 mix-blend-overlay"
style={{
backgroundImage:
"repeating-linear-gradient(115deg, rgba(255,255,255,0.22) 0 2px, transparent 2px 9px)",
}}
/>
<div className="absolute inset-0 grid place-items-center">
<div
className={`grid h-24 w-24 place-items-center rounded-full bg-black/25 ring-1 ring-white/30 backdrop-blur-sm ${
playing && !reduced ? "mpl-disc-spin" : ""
}`}
>
<div className="h-5 w-5 rounded-full bg-white/85" />
</div>
</div>
<AnimatePresence>
{playing ? (
<motion.div
key="eq"
initial={reduced ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={reduced ? undefined : { opacity: 0, y: 6 }}
transition={{ duration: 0.22 }}
aria-hidden="true"
className="absolute bottom-3 left-3 flex h-6 items-end gap-[3px] rounded-md bg-black/35 px-2 py-1.5 backdrop-blur-sm"
>
{[0, 1, 2, 3].map((i) => (
<span
key={i}
className="mpl-eq-bar block w-[3px] rounded-full bg-white"
style={{
height: "100%",
animationDelay: `${i * 130}ms`,
animationDuration: `${760 + i * 90}ms`,
}}
/>
))}
</motion.div>
) : null}
</AnimatePresence>
</div>
<div className="relative mt-6 min-h-[4.5rem]">
<AnimatePresence mode="wait">
<motion.div
key={track.id}
initial={reduced ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduced ? undefined : { opacity: 0, y: -8 }}
transition={{ duration: 0.24, ease: [0.22, 1, 0.36, 1] }}
>
<h3 className="truncate text-lg font-semibold tracking-tight">
{track.title}
</h3>
<p className="truncate text-sm text-slate-600 dark:text-zinc-400">
{track.artist}
</p>
<p className="mt-0.5 truncate text-xs text-slate-500 dark:text-zinc-500">
{track.album} · {track.year}
</p>
</motion.div>
</AnimatePresence>
</div>
{/* Waveform scrubber */}
<div className="relative mt-4 rounded-lg p-1 ring-2 ring-transparent transition-shadow focus-within:ring-indigo-500">
<div
aria-hidden="true"
className="flex h-14 items-end gap-[2px] rounded-lg"
>
{bars.map((h, i) => (
<span
key={i}
className="flex-1 rounded-sm transition-[background-color,opacity] duration-200"
style={{
height: `${Math.max(8, h)}%`,
backgroundColor:
i <= activeBar ? track.hueFrom : "currentColor",
opacity: i <= activeBar ? 1 : 0.18,
}}
/>
))}
</div>
<input
type="range"
min={0}
max={duration}
step={1}
value={Math.floor(elapsed)}
onChange={(e) => setElapsed(Number(e.target.value))}
onPointerDown={() => setScrubbing(true)}
onPointerUp={() => setScrubbing(false)}
onKeyDown={() => setScrubbing(true)}
onKeyUp={() => setScrubbing(false)}
onBlur={() => setScrubbing(false)}
aria-label={`Seek within ${track.title}`}
aria-valuetext={`${fmtSpoken(elapsed)} of ${fmtSpoken(
duration
)}`}
className="absolute inset-0 h-full w-full cursor-pointer appearance-none bg-transparent opacity-0 focus:outline-none"
/>
</div>
<div className="mt-2 flex items-center justify-between font-mono text-[11px] tabular-nums text-slate-500 dark:text-zinc-500">
<span>{fmt(elapsed)}</span>
<span>-{fmt(Math.max(0, duration - elapsed))}</span>
</div>
{/* Transport */}
<div className="mt-5 flex items-center justify-center gap-2">
<button
type="button"
onClick={() => setShuffle((v) => !v)}
aria-pressed={shuffle}
aria-label="Shuffle"
className={`grid h-9 w-9 place-items-center rounded-full transition-colors focus: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-zinc-900 ${
shuffle
? "bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300"
: "text-slate-500 hover:bg-slate-100 dark:text-zinc-500 dark:hover:bg-zinc-800"
}`}
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M16 3h5v5" />
<path d="M4 20 21 3" />
<path d="M21 16v5h-5" />
<path d="M15 15l6 6" />
<path d="M4 4l5 5" />
</svg>
</button>
<button
type="button"
onClick={() => goTo(index - 1)}
aria-label="Previous track"
className="grid h-10 w-10 place-items-center rounded-full text-slate-700 transition-colors hover:bg-slate-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-zinc-300 dark:hover:bg-zinc-800 dark:focus-visible:ring-offset-zinc-900"
>
<svg
viewBox="0 0 24 24"
className="h-5 w-5"
fill="currentColor"
aria-hidden="true"
>
<path d="M7 5h2v14H7z" />
<path d="M19 5.5v13a.75.75 0 0 1-1.16.63l-9.2-6.5a.75.75 0 0 1 0-1.26l9.2-6.5A.75.75 0 0 1 19 5.5z" />
</svg>
</button>
<button
type="button"
onClick={() => setPlaying((v) => !v)}
aria-label={playing ? `Pause ${track.title}` : `Play ${track.title}`}
className="grid h-14 w-14 place-items-center rounded-full bg-slate-900 text-white shadow-lg transition-transform hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white active:scale-95 dark:bg-white dark:text-zinc-900 dark:focus-visible:ring-offset-zinc-900"
>
{playing ? (
<svg
viewBox="0 0 24 24"
className="h-5 w-5"
fill="currentColor"
aria-hidden="true"
>
<path d="M8 5h3v14H8zM13 5h3v14h-3z" />
</svg>
) : (
<svg
viewBox="0 0 24 24"
className="ml-0.5 h-5 w-5"
fill="currentColor"
aria-hidden="true"
>
<path d="M8 5.14v13.72a.75.75 0 0 0 1.14.64l11.1-6.86a.75.75 0 0 0 0-1.28L9.14 4.5A.75.75 0 0 0 8 5.14z" />
</svg>
)}
</button>
<button
type="button"
onClick={() => goTo(index + 1)}
aria-label="Next track"
className="grid h-10 w-10 place-items-center rounded-full text-slate-700 transition-colors hover:bg-slate-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-zinc-300 dark:hover:bg-zinc-800 dark:focus-visible:ring-offset-zinc-900"
>
<svg
viewBox="0 0 24 24"
className="h-5 w-5"
fill="currentColor"
aria-hidden="true"
>
<path d="M15 5h2v14h-2z" />
<path d="M5 5.5v13a.75.75 0 0 0 1.16.63l9.2-6.5a.75.75 0 0 0 0-1.26l-9.2-6.5A.75.75 0 0 0 5 5.5z" />
</svg>
</button>
<button
type="button"
onClick={() => setRepeat((v) => !v)}
aria-pressed={repeat}
aria-label="Repeat this track"
className={`grid h-9 w-9 place-items-center rounded-full transition-colors focus: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-zinc-900 ${
repeat
? "bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300"
: "text-slate-500 hover:bg-slate-100 dark:text-zinc-500 dark:hover:bg-zinc-800"
}`}
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M17 2l4 4-4 4" />
<path d="M3 11v-1a4 4 0 0 1 4-4h14" />
<path d="M7 22l-4-4 4-4" />
<path d="M21 13v1a4 4 0 0 1-4 4H3" />
</svg>
</button>
</div>
{/* Volume */}
<div className="mt-5 flex items-center gap-3">
<button
type="button"
onClick={() => setMuted((v) => !v)}
aria-pressed={muted}
aria-label={muted ? "Unmute" : "Mute"}
className="grid h-8 w-8 shrink-0 place-items-center rounded-md text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-zinc-500 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M11 5 6 9H3v6h3l5 4V5z" />
{muted || volume === 0 ? (
<>
<path d="M22 9l-6 6" />
<path d="M16 9l6 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>
<input
type="range"
min={0}
max={100}
step={1}
value={muted ? 0 : volume}
onChange={(e) => {
setVolume(Number(e.target.value));
setMuted(false);
}}
aria-label="Volume"
aria-valuetext={`${muted ? 0 : volume} percent`}
className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-zinc-800 dark:accent-indigo-400 dark:focus-visible:ring-offset-zinc-900"
/>
<span className="w-8 shrink-0 text-right font-mono text-[11px] tabular-nums text-slate-500 dark:text-zinc-500">
{muted ? 0 : volume}
</span>
</div>
</div>
{/* Playlist */}
<div className="rounded-3xl border border-slate-200 bg-white/60 p-2 backdrop-blur-xl sm:p-3 dark:border-zinc-800 dark:bg-zinc-900/40">
<div className="flex items-center justify-between px-3 py-2.5">
<h3 className="text-sm font-semibold tracking-tight text-slate-700 dark:text-zinc-300">
Up next
</h3>
<span className="font-mono text-[11px] tabular-nums text-slate-500 dark:text-zinc-500">
{index + 1}/{TRACKS.length}
</span>
</div>
<ul
ref={listRef}
onKeyDown={onListKeyDown}
className="flex flex-col gap-0.5"
>
{TRACKS.map((t, i) => {
const isCurrent = i === index;
const isLiked = Boolean(liked[t.id]);
return (
<li key={t.id} className="relative">
{isCurrent ? (
<motion.span
layoutId={reduced ? undefined : "mpl-active-row"}
aria-hidden="true"
className="absolute inset-0 rounded-xl bg-slate-100 dark:bg-zinc-800/70"
transition={{
type: "spring",
stiffness: 420,
damping: 38,
}}
/>
) : null}
<div className="relative flex items-center gap-1">
<button
type="button"
data-track-item
aria-current={isCurrent ? "true" : undefined}
onClick={() => {
if (isCurrent) {
setPlaying((v) => !v);
} else {
goTo(i);
setPlaying(true);
}
}}
className="group flex min-w-0 flex-1 items-center gap-3 rounded-xl px-3 py-3 text-left transition-colors hover:bg-slate-100/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:hover:bg-zinc-800/50 dark:focus-visible:ring-offset-zinc-950"
>
<span className="relative grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-lg">
<span
aria-hidden="true"
className="absolute inset-0"
style={{
background: `linear-gradient(140deg, ${t.hueFrom}, ${t.hueTo})`,
}}
/>
{isCurrent && playing ? (
<span
aria-hidden="true"
className="relative flex h-4 items-end gap-[2px]"
>
{[0, 1, 2].map((b) => (
<span
key={b}
className="mpl-eq-bar block h-full w-[2px] rounded-full bg-white"
style={{
animationDelay: `${b * 150}ms`,
animationDuration: `${820 + b * 110}ms`,
}}
/>
))}
</span>
) : (
<span
aria-hidden="true"
className="relative font-mono text-[11px] font-semibold tabular-nums text-white/90 group-hover:opacity-0"
>
{i + 1}
</span>
)}
{!(isCurrent && playing) ? (
<svg
viewBox="0 0 24 24"
className="absolute h-4 w-4 text-white opacity-0 transition-opacity group-hover:opacity-100"
fill="currentColor"
aria-hidden="true"
>
<path d="M8 5.14v13.72a.75.75 0 0 0 1.14.64l11.1-6.86a.75.75 0 0 0 0-1.28L9.14 4.5A.75.75 0 0 0 8 5.14z" />
</svg>
) : null}
</span>
<span className="min-w-0 flex-1">
<span
className={`block truncate text-sm font-medium ${
isCurrent
? "text-indigo-700 dark:text-indigo-300"
: "text-slate-800 dark:text-zinc-200"
}`}
>
{t.title}
</span>
<span className="block truncate text-xs text-slate-500 dark:text-zinc-500">
{t.artist} · {t.album}
</span>
</span>
<span className="shrink-0 font-mono text-[11px] tabular-nums text-slate-500 dark:text-zinc-500">
{fmt(t.seconds)}
</span>
</button>
<button
type="button"
onClick={() =>
setLiked((prev) => ({ ...prev, [t.id]: !prev[t.id] }))
}
aria-pressed={isLiked}
aria-label={`${isLiked ? "Unlike" : "Like"} ${t.title} by ${
t.artist
}`}
className={`mr-1 grid h-9 w-9 shrink-0 place-items-center rounded-lg transition-colors focus: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-zinc-950 ${
isLiked
? "text-rose-500 dark:text-rose-400"
: "text-slate-400 hover:text-slate-700 dark:text-zinc-600 dark:hover:text-zinc-300"
}`}
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill={isLiked ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M20.8 5.6a5 5 0 0 0-7.1 0L12 7.3l-1.7-1.7a5 5 0 1 0-7.1 7.1l8.8 8.8 8.8-8.8a5 5 0 0 0 0-7.1z" />
</svg>
</button>
</div>
</li>
);
})}
</ul>
<p
aria-live="polite"
className="px-3 py-3 text-xs text-slate-500 dark:text-zinc-500"
>
{playing ? "Playing" : "Paused"}: {track.title} — {track.artist}
</p>
</div>
</div>
</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 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.

