A reorderable up-next track queue with a highlighted now-playing row and an animated equalizer.
npx shadcn@latest add @paragon/playlist-queue"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronDown, ChevronUp, GripVertical } from "lucide-react";
import { cn } from "@/lib/utils";
export interface QueueTrack {
id: string;
title: string;
artist: string;
/** Duration label, e.g. "3:24". */
duration: string;
}
export interface PlaylistQueueProps extends React.ComponentProps<"div"> {
tracks?: QueueTrack[];
/** Id of the currently-playing track. */
nowPlayingId?: string;
}
const DEFAULT_TRACKS: QueueTrack[] = [
{ id: "1", title: "Midnight City", artist: "M83", duration: "4:03" },
{ id: "2", title: "Redbone", artist: "Childish Gambino", duration: "5:27" },
{ id: "3", title: "Nightcall", artist: "Kavinsky", duration: "4:18" },
{ id: "4", title: "Instant Crush", artist: "Daft Punk", duration: "5:37" },
{ id: "5", title: "The Less I Know", artist: "Tame Impala", duration: "3:39" },
];
/**
* A reorderable up-next queue with a highlighted now-playing row and an
* animated equalizer. Rows reorder via keyboard-accessible up/down buttons
* (a drag handle is shown for affordance); the layout animation is a spring
* that flattens to an instant swap under reduced motion.
*/
export function PlaylistQueue({
tracks = DEFAULT_TRACKS,
nowPlayingId = "1",
className,
...props
}: PlaylistQueueProps) {
const [items, setItems] = React.useState(tracks);
const reduced = useReducedMotion();
function move(index: number, delta: number) {
const target = index + delta;
if (target < 0 || target >= items.length) return;
setItems((prev) => {
const next = [...prev];
const [row] = next.splice(index, 1);
next.splice(target, 0, row);
return next;
});
}
return (
<div
data-slot="playlist-queue"
className={cn(
"w-full max-w-md rounded-xl bg-card p-2 text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="px-3 py-2">
<h3 className="text-sm font-semibold">Up next</h3>
<p className="text-xs text-muted-foreground">
{items.length} tracks in queue
</p>
</div>
<ul className="flex flex-col">
<AnimatePresence initial={false}>
{items.map((track, i) => {
const active = track.id === nowPlayingId;
return (
<motion.li
key={track.id}
layout={reduced ? false : "position"}
transition={{ type: "spring", duration: 0.35, bounce: 0 }}
className={cn(
"group flex items-center gap-2 rounded-lg px-2 py-2 transition-colors duration-150",
active ? "bg-accent" : "hover:bg-accent/60",
)}
>
<span
aria-hidden
className="hidden text-muted-foreground/60 @[22rem]:block"
>
<GripVertical className="size-4" />
</span>
<span className="grid w-5 shrink-0 place-items-center text-xs tabular-nums text-muted-foreground">
{active ? (
<Equalizer />
) : (
<span className="group-hover:opacity-0">{i + 1}</span>
)}
</span>
<div className="min-w-0 flex-1">
<div
className={cn(
"truncate text-sm",
active ? "font-semibold text-foreground" : "font-medium",
)}
>
{track.title}
</div>
<div className="truncate text-xs text-muted-foreground">
{track.artist}
</div>
</div>
<span className="font-mono text-xs tabular-nums text-muted-foreground">
{track.duration}
</span>
<div className="flex flex-col">
<ReorderButton
label={`Move ${track.title} up`}
disabled={i === 0}
onClick={() => move(i, -1)}
>
<ChevronUp className="size-3.5" />
</ReorderButton>
<ReorderButton
label={`Move ${track.title} down`}
disabled={i === items.length - 1}
onClick={() => move(i, 1)}
>
<ChevronDown className="size-3.5" />
</ReorderButton>
</div>
</motion.li>
);
})}
</AnimatePresence>
</ul>
</div>
);
}
function ReorderButton({
label,
disabled,
onClick,
children,
}: {
label: string;
disabled?: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
disabled={disabled}
onClick={onClick}
className="pressable grid size-5 place-items-center rounded text-muted-foreground transition-colors duration-150 hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
>
{children}
</button>
);
}
function Equalizer() {
return (
<>
<style href="paragon-playlist-queue" precedence="paragon">{`
@keyframes paragon-eq-bar {
0%, 100% { transform: scaleY(0.35); }
50% { transform: scaleY(1); }
}
@media (prefers-reduced-motion: reduce) {
[data-eq-bar] { animation: none !important; transform: scaleY(0.7); }
}
`}</style>
<span
aria-hidden
className="flex h-3.5 items-end gap-[2px] text-foreground"
>
{[0, 1, 2].map((n) => (
<span
key={n}
data-eq-bar
className="w-[2px] origin-bottom rounded-full bg-current"
style={{
height: "100%",
animation: `paragon-eq-bar ${0.9 + n * 0.25}s var(--ease-in-out) ${n * 0.15}s infinite`,
}}
/>
))}
</span>
</>
);
}