Web InnoventixFreeCode

Media Audio Player

Original · free

audio player with progress and volume

byWeb InnoventixReact + Tailwind
mediaaudioplayer
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-audio-player.json
media-audio-player.tsx
"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

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 →