Media

Now Playing

A Spotify-style now-playing bar with cover art, a scrubbable progress bar, transport controls, and a volume slider.

Install

npx shadcn@latest add @paragon/now-playing

now-playing.tsx

"use client";

import * as React from "react";
import {
  Heart,
  Pause,
  Play,
  Repeat,
  Shuffle,
  SkipBack,
  SkipForward,
  Volume2,
  VolumeX,
} from "lucide-react";
import { cn } from "@/lib/utils";

export interface NowPlayingTrack {
  title: string;
  artist: string;
  /** Total duration in seconds. */
  duration: number;
  /** Optional cover accent color used for the art placeholder. */
  color?: string;
}

export interface NowPlayingProps
  extends Omit<React.ComponentProps<"div">, "onChange"> {
  track?: NowPlayingTrack;
  /** Initial playhead position in seconds. */
  position?: number;
  /** Initial play state. */
  playing?: boolean;
  /** Initial volume, 0–1. */
  volume?: number;
}

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

/**
 * A Spotify-style now-playing bar: cover art, title/artist, a scrubbable
 * progress bar with tabular timestamps, transport controls, and a volume
 * slider. The progress bar and volume are real range inputs (keyboard- and
 * pointer-driven); the playhead advances on a ticking interval when playing,
 * paused when the tab is hidden or reduced motion is requested.
 */
export function NowPlaying({
  track = {
    title: "Midnight City",
    artist: "M83",
    duration: 244,
    color: "#7c3aed",
  },
  position = 72,
  playing = false,
  volume = 0.7,
  className,
  ...props
}: NowPlayingProps) {
  const [isPlaying, setIsPlaying] = React.useState(playing);
  const [pos, setPos] = React.useState(Math.min(position, track.duration));
  const [vol, setVol] = React.useState(volume);
  const [muted, setMuted] = React.useState(false);
  const [liked, setLiked] = React.useState(false);

  React.useEffect(() => {
    if (!isPlaying) return;
    const media = window.matchMedia("(prefers-reduced-motion: reduce)");
    const id = window.setInterval(() => {
      if (document.hidden) return;
      setPos((p) => {
        if (p >= track.duration) {
          setIsPlaying(false);
          return track.duration;
        }
        return p + 1;
      });
    }, 1000);
    // Keep the interval; reduced motion only affects decorative transitions.
    void media;
    return () => window.clearInterval(id);
  }, [isPlaying, track.duration]);

  const pct = track.duration ? (pos / track.duration) * 100 : 0;
  const effectiveVol = muted ? 0 : vol;

  return (
    <div
      data-slot="now-playing"
      className={cn(
        "flex w-full max-w-xl flex-col gap-3 rounded-xl bg-card p-4 text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <div className="flex items-center gap-3">
        <span
          aria-hidden
          className="grid size-12 shrink-0 place-items-center rounded-md text-white/90"
          style={{
            background: `linear-gradient(135deg, ${track.color ?? "#7c3aed"}, color-mix(in oklch, ${track.color ?? "#7c3aed"} 50%, black))`,
          }}
        >
          <Play className="size-4 fill-current" />
        </span>
        <div className="min-w-0 flex-1">
          <div className="truncate text-sm font-semibold">{track.title}</div>
          <div className="truncate text-xs text-muted-foreground">
            {track.artist}
          </div>
        </div>
        <button
          type="button"
          aria-label={liked ? "Remove from liked" : "Add to liked"}
          aria-pressed={liked}
          onClick={() => setLiked((l) => !l)}
          className="pressable grid size-8 place-items-center rounded-full text-muted-foreground transition-colors duration-150 hover:text-foreground"
        >
          <Heart
            className={cn("size-4", liked && "fill-current text-destructive")}
          />
        </button>
      </div>

      <div className="flex items-center gap-2">
        <span className="w-9 text-right font-mono text-[11px] tabular-nums text-muted-foreground">
          {fmt(pos)}
        </span>
        <div className="relative flex-1">
          <div className="pointer-events-none absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-muted" />
          <div
            className="pointer-events-none absolute top-1/2 left-0 h-1 -translate-y-1/2 rounded-full bg-foreground"
            style={{ width: `${pct}%` }}
          />
          <input
            type="range"
            aria-label="Seek"
            min={0}
            max={track.duration}
            step={1}
            value={pos}
            onChange={(e) => setPos(Number(e.target.value))}
            className="paragon-range relative z-10 h-4 w-full cursor-pointer"
          />
        </div>
        <span className="w-9 font-mono text-[11px] tabular-nums text-muted-foreground">
          {fmt(track.duration)}
        </span>
      </div>

      <div className="flex items-center justify-between">
        <div className="flex items-center gap-1">
          <TransportButton label="Shuffle">
            <Shuffle className="size-4" />
          </TransportButton>
          <TransportButton label="Previous">
            <SkipBack className="size-4 fill-current" />
          </TransportButton>
          <button
            type="button"
            aria-label={isPlaying ? "Pause" : "Play"}
            onClick={() => setIsPlaying((p) => !p)}
            className="pressable grid size-9 place-items-center rounded-full bg-foreground text-background transition-colors duration-150"
          >
            {isPlaying ? (
              <Pause className="size-4 fill-current" />
            ) : (
              <Play className="size-4 translate-x-px fill-current" />
            )}
          </button>
          <TransportButton label="Next">
            <SkipForward className="size-4 fill-current" />
          </TransportButton>
          <TransportButton label="Repeat">
            <Repeat className="size-4" />
          </TransportButton>
        </div>

        <div className="flex items-center gap-2">
          <button
            type="button"
            aria-label={muted ? "Unmute" : "Mute"}
            onClick={() => setMuted((m) => !m)}
            className="pressable grid size-8 place-items-center rounded-full text-muted-foreground transition-colors duration-150 hover:text-foreground"
          >
            {effectiveVol === 0 ? (
              <VolumeX className="size-4" />
            ) : (
              <Volume2 className="size-4" />
            )}
          </button>
          <div className="relative w-24">
            <div className="pointer-events-none absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-muted" />
            <div
              className="pointer-events-none absolute top-1/2 left-0 h-1 -translate-y-1/2 rounded-full bg-foreground"
              style={{ width: `${effectiveVol * 100}%` }}
            />
            <input
              type="range"
              aria-label="Volume"
              min={0}
              max={1}
              step={0.01}
              value={effectiveVol}
              onChange={(e) => {
                setVol(Number(e.target.value));
                setMuted(false);
              }}
              className="paragon-range relative z-10 h-4 w-full cursor-pointer"
            />
          </div>
        </div>
      </div>
    </div>
  );
}

function TransportButton({
  label,
  children,
}: {
  label: string;
  children: React.ReactNode;
}) {
  return (
    <button
      type="button"
      aria-label={label}
      className="pressable grid size-8 place-items-center rounded-full text-muted-foreground transition-colors duration-150 hover:text-foreground"
    >
      {children}
    </button>
  );
}