Web InnoventixFreeCode

Media Playlist

Original · free

music playlist with now-playing

byWeb InnoventixReact + Tailwind
mediaplaylist
Open

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
media-playlist.tsx
"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

motion

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 quote

Similar components

Browse all →