Web InnoventixFreeCode

Music Card

Original · free

A compact music player card with artwork and transport controls.

byWeb InnoventixReact + Tailwind
cardmusiccards
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/card-music.json
card-music.tsx
"use client";

import { useState, useRef, useEffect, useCallback } from "react";
import { useReducedMotion } from "motion/react";

type Track = {
  id: string;
  title: string;
  artist: string;
  album: string;
  art: string;
  duration: number; // seconds
};

const PLAYLIST: Track[] = [
  {
    id: "t1",
    title: "Blinding Lights",
    artist: "The Weeknd",
    album: "After Hours",
    art: "/img/gallery/g07.webp",
    duration: 200,
  },
  {
    id: "t2",
    title: "As It Was",
    artist: "Harry Styles",
    album: "Harry's House",
    art: "/img/gallery/g12.webp",
    duration: 167,
  },
  {
    id: "t3",
    title: "Flowers",
    artist: "Miley Cyrus",
    album: "Endless Summer Vacation",
    art: "/img/gallery/g21.webp",
    duration: 200,
  },
  {
    id: "t4",
    title: "Midnight City",
    artist: "M83",
    album: "Hurry Up, We're Dreaming",
    art: "/img/gallery/g29.webp",
    duration: 244,
  },
];

function fmt(sec: number): string {
  const s = Math.max(0, Math.floor(sec));
  const m = Math.floor(s / 60);
  const r = s % 60;
  return `${m}:${r.toString().padStart(2, "0")}`;
}

/* ---------- inline glyphs ---------- */

function PlayIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
      <path d="M8 5.14v13.72a1 1 0 0 0 1.5.86l11.14-6.86a1 1 0 0 0 0-1.72L9.5 4.28A1 1 0 0 0 8 5.14Z" />
    </svg>
  );
}

function PauseIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
      <rect x="6" y="5" width="4" height="14" rx="1.2" />
      <rect x="14" y="5" width="4" height="14" rx="1.2" />
    </svg>
  );
}

function PrevIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
      <path d="M7 5a1 1 0 0 1 2 0v5.2l8.5-5.06A1 1 0 0 1 19 6v12a1 1 0 0 1-1.5.86L9 13.8V19a1 1 0 0 1-2 0V5Z" />
    </svg>
  );
}

function NextIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
      <path d="M17 5a1 1 0 0 0-2 0v5.2L6.5 5.14A1 1 0 0 0 5 6v12a1 1 0 0 0 1.5.86L15 13.8V19a1 1 0 0 0 2 0V5Z" />
    </svg>
  );
}

function ShuffleIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className} 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>
  );
}

function RepeatIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="m17 2 4 4-4 4" />
      <path d="M3 11v-1a4 4 0 0 1 4-4h14" />
      <path d="m7 22-4-4 4-4" />
      <path d="M21 13v1a4 4 0 0 1-4 4H3" />
    </svg>
  );
}

function HeartIcon({ filled, className }: { filled: boolean; className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1-1.1a5.5 5.5 0 0 0-7.8 7.8l1 1.1L12 21l7.8-7.5 1-1.1a5.5 5.5 0 0 0 0-7.8Z" />
    </svg>
  );
}

function VolumeIcon({ muted, className }: { muted: boolean; className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="M11 5 6 9H2v6h4l5 4V5Z" fill="currentColor" stroke="none" />
      {muted ? (
        <>
          <path d="m22 9-6 6" />
          <path d="m16 9 6 6" />
        </>
      ) : (
        <>
          <path d="M15.5 8.5a5 5 0 0 1 0 7" />
          <path d="M18.5 5.5a9 9 0 0 1 0 13" />
        </>
      )}
    </svg>
  );
}

/* ---------- the player ---------- */

function MusicPlayerCard({ compact = false }: { compact?: boolean }) {
  const reduce = useReducedMotion();
  const [index, setIndex] = useState(0);
  const [playing, setPlaying] = useState(false);
  const [elapsed, setElapsed] = useState(42);
  const [liked, setLiked] = useState<Record<string, boolean>>({ t1: true });
  const [shuffle, setShuffle] = useState(false);
  const [repeat, setRepeat] = useState(false);
  const [muted, setMuted] = useState(false);
  const [scrubbing, setScrubbing] = useState(false);

  const track = PLAYLIST[index];
  const rafRef = useRef<number | null>(null);
  const lastRef = useRef<number>(0);

  const goNext = useCallback(() => {
    setElapsed(0);
    setIndex((i) => {
      if (shuffle) {
        if (PLAYLIST.length === 1) return i;
        let n = i;
        while (n === i) n = Math.floor(Math.random() * PLAYLIST.length);
        return n;
      }
      return (i + 1) % PLAYLIST.length;
    });
  }, [shuffle]);

  const goPrev = useCallback(() => {
    if (elapsed > 3) {
      setElapsed(0);
      return;
    }
    setElapsed(0);
    setIndex((i) => (i - 1 + PLAYLIST.length) % PLAYLIST.length);
  }, [elapsed]);

  // simulated playback clock
  useEffect(() => {
    if (!playing || scrubbing) return;
    lastRef.current = performance.now();
    const tick = (now: number) => {
      const dt = (now - lastRef.current) / 1000;
      lastRef.current = now;
      setElapsed((e) => {
        const next = e + dt;
        if (next >= track.duration) {
          if (repeat) return 0;
          queueMicrotask(goNext);
          return track.duration;
        }
        return next;
      });
      rafRef.current = requestAnimationFrame(tick);
    };
    rafRef.current = requestAnimationFrame(tick);
    return () => {
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
    };
  }, [playing, scrubbing, track.duration, repeat, goNext]);

  const pct = Math.min(100, (elapsed / track.duration) * 100);
  const isLiked = !!liked[track.id];

  const seekRef = useRef<HTMLInputElement | null>(null);

  return (
    <div
      className={[
        "group/card w-full overflow-hidden rounded-3xl border text-left shadow-xl shadow-slate-900/5 backdrop-blur",
        "border-slate-200/80 bg-white/90",
        "dark:border-white/10 dark:bg-slate-900/70 dark:shadow-black/40",
        compact ? "max-w-sm" : "max-w-md",
      ].join(" ")}
    >
      {/* artwork */}
      <div className={compact ? "p-4" : "p-5"}>
        <div className="relative overflow-hidden rounded-2xl">
          <div
            className={[
              "relative aspect-square w-full",
              playing && !reduce ? "cardmus-float" : "",
            ].join(" ")}
          >
            {/* eslint-disable-next-line @next/next/no-img-element */}
            <img
              src={track.art}
              alt={`${track.album} album cover`}
              loading="lazy"
              draggable={false}
              className="h-full w-full select-none object-cover"
            />
            {/* subtle sheen */}
            <div className="pointer-events-none absolute inset-0 bg-gradient-to-tr from-black/25 via-transparent to-white/10" />

            {/* now-playing pill */}
            <div className="absolute left-3 top-3 flex items-center gap-1.5 rounded-full bg-black/45 px-2.5 py-1 text-[11px] font-medium text-white backdrop-blur-sm">
              <span className="flex h-3 items-end gap-[2px]" aria-hidden="true">
                {[0, 1, 2].map((b) => (
                  <span
                    key={b}
                    className={[
                      "w-[3px] rounded-full bg-emerald-400",
                      playing && !reduce ? `cardmus-bar cardmus-bar-${b}` : "",
                    ].join(" ")}
                    style={playing && !reduce ? undefined : { height: "40%" }}
                  />
                ))}
              </span>
              {playing ? "Now Playing" : "Paused"}
            </div>

            {/* like */}
            <button
              type="button"
              onClick={() => setLiked((m) => ({ ...m, [track.id]: !m[track.id] }))}
              aria-pressed={isLiked}
              aria-label={isLiked ? `Remove ${track.title} from favourites` : `Add ${track.title} to favourites`}
              className={[
                "absolute right-3 top-3 grid h-9 w-9 place-items-center rounded-full backdrop-blur-sm transition",
                "outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-black/30",
                isLiked
                  ? "bg-rose-500/90 text-white"
                  : "bg-black/40 text-white hover:bg-black/55",
              ].join(" ")}
            >
              <HeartIcon filled={isLiked} className={["h-[18px] w-[18px]", isLiked && !reduce ? "cardmus-pop" : ""].join(" ")} />
            </button>
          </div>
        </div>
      </div>

      {/* meta + controls */}
      <div className={compact ? "px-4 pb-5" : "px-6 pb-6"}>
        <p className="text-[11px] font-semibold uppercase tracking-widest text-indigo-500 dark:text-indigo-400">
          {track.album}
        </p>
        <h3 className="mt-1 truncate text-lg font-bold text-slate-900 dark:text-white">
          {track.title}
        </h3>
        <p className="truncate text-sm text-slate-500 dark:text-slate-400">{track.artist}</p>

        {/* seek */}
        <div className="mt-4">
          <div className="relative h-1.5 w-full rounded-full bg-slate-200 dark:bg-white/10">
            <div
              className="absolute inset-y-0 left-0 rounded-full bg-slate-900 dark:bg-white"
              style={{ width: `${pct}%` }}
            />
            <span
              className="absolute top-1/2 h-3.5 w-3.5 -translate-y-1/2 -translate-x-1/2 rounded-full bg-slate-900 shadow ring-2 ring-white transition-transform group-focus-within/card:scale-100 dark:bg-white dark:ring-slate-900"
              style={{ left: `${pct}%` }}
              aria-hidden="true"
            />
            <input
              ref={seekRef}
              type="range"
              min={0}
              max={Math.floor(track.duration)}
              step={1}
              value={Math.floor(elapsed)}
              onChange={(e) => setElapsed(Number(e.target.value))}
              onPointerDown={() => setScrubbing(true)}
              onPointerUp={() => setScrubbing(false)}
              onBlur={() => setScrubbing(false)}
              aria-label={`Seek. ${fmt(elapsed)} of ${fmt(track.duration)}`}
              className="absolute inset-0 h-full w-full cursor-pointer appearance-none bg-transparent opacity-0"
            />
          </div>
          <div className="mt-1.5 flex justify-between text-[11px] font-medium tabular-nums text-slate-500 dark:text-slate-400">
            <span>{fmt(elapsed)}</span>
            <span>-{fmt(track.duration - elapsed)}</span>
          </div>
        </div>

        {/* transport */}
        <div className="mt-4 flex items-center justify-between">
          <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 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
                ? "text-indigo-600 dark:text-indigo-400"
                : "text-slate-400 hover:text-slate-700 dark:text-slate-500 dark:hover:text-slate-200",
            ].join(" ")}
          >
            <ShuffleIcon className="h-[18px] w-[18px]" />
          </button>

          <div className="flex items-center gap-2">
            <button
              type="button"
              onClick={goPrev}
              aria-label="Previous track"
              className="grid h-10 w-10 place-items-center rounded-full text-slate-700 transition hover:bg-slate-100 active:scale-95 outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-200 dark:hover:bg-white/10 dark:focus-visible:ring-offset-slate-900"
            >
              <PrevIcon className="h-5 w-5" />
            </button>

            <button
              type="button"
              onClick={() => setPlaying((p) => !p)}
              aria-pressed={playing}
              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 shadow-slate-900/25 transition hover:scale-[1.04] active:scale-95 outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white dark:text-slate-900 dark:shadow-black/40 dark:focus-visible:ring-offset-slate-900"
            >
              {playing ? <PauseIcon className="h-6 w-6" /> : <PlayIcon className="h-6 w-6 translate-x-[1px]" />}
            </button>

            <button
              type="button"
              onClick={goNext}
              aria-label="Next track"
              className="grid h-10 w-10 place-items-center rounded-full text-slate-700 transition hover:bg-slate-100 active:scale-95 outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-200 dark:hover:bg-white/10 dark:focus-visible:ring-offset-slate-900"
            >
              <NextIcon className="h-5 w-5" />
            </button>
          </div>

          <button
            type="button"
            onClick={() => setRepeat((v) => !v)}
            aria-pressed={repeat}
            aria-label="Repeat"
            className={[
              "grid h-9 w-9 place-items-center rounded-full transition 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",
              repeat
                ? "text-indigo-600 dark:text-indigo-400"
                : "text-slate-400 hover:text-slate-700 dark:text-slate-500 dark:hover:text-slate-200",
            ].join(" ")}
          >
            <RepeatIcon className="h-[18px] w-[18px]" />
          </button>
        </div>

        {/* volume row */}
        <div className="mt-4 flex items-center gap-3 border-t border-slate-100 pt-4 dark:border-white/5">
          <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-full text-slate-500 transition hover:text-slate-800 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:text-slate-100 dark:focus-visible:ring-offset-slate-900"
          >
            <VolumeIcon muted={muted} className="h-[18px] w-[18px]" />
          </button>
          <div className="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-200 dark:bg-white/10">
            <div
              className="h-full rounded-full bg-slate-400 transition-[width] dark:bg-slate-500"
              style={{ width: muted ? "0%" : "68%" }}
            />
          </div>
          <span className="w-8 text-right text-[11px] font-medium tabular-nums text-slate-400 dark:text-slate-500">
            {muted ? "0" : "68"}
          </span>
        </div>
      </div>
    </div>
  );
}

/* ---------- showcase ---------- */

export default function CardMusic() {
  return (
    <section className="relative w-full bg-slate-50 px-6 py-20 dark:bg-slate-950 sm:px-10">
      <style>{`
        @keyframes cardmus-float {
          0%, 100% { transform: translateY(0); }
          50% { transform: translateY(-6px); }
        }
        @keyframes cardmus-eq {
          0%, 100% { height: 30%; }
          50% { height: 100%; }
        }
        @keyframes cardmus-pop {
          0% { transform: scale(1); }
          40% { transform: scale(1.35); }
          100% { transform: scale(1); }
        }
        .cardmus-float { animation: cardmus-float 5s ease-in-out infinite; }
        .cardmus-bar { animation: cardmus-eq 0.9s ease-in-out infinite; }
        .cardmus-bar-0 { animation-delay: 0s; }
        .cardmus-bar-1 { animation-delay: 0.25s; }
        .cardmus-bar-2 { animation-delay: 0.5s; }
        .cardmus-pop { animation: cardmus-pop 0.4s ease-out; }
        @media (prefers-reduced-motion: reduce) {
          .cardmus-float, .cardmus-bar, .cardmus-pop { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-5xl">
        <div className="mb-12 text-center">
          <h2 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-white">
            Music player card
          </h2>
          <p className="mx-auto mt-3 max-w-xl text-sm text-slate-500 dark:text-slate-400">
            Tactile transport controls, a seekable timeline, working like &amp; shuffle
            state, and a live equalizer while playing.
          </p>
        </div>

        <div className="flex flex-wrap items-start justify-center gap-8">
          <MusicPlayerCard />
          <MusicPlayerCard compact />
        </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 →