A Spotify-style now-playing bar with cover art, a scrubbable progress bar, transport controls, and a volume slider.
npx shadcn@latest add @paragon/now-playing"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>
);
}