Media

Playlist Queue

A reorderable up-next track queue with a highlighted now-playing row and an animated equalizer.

Install

npx shadcn@latest add @paragon/playlist-queue

playlist-queue.tsx

"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>
    </>
  );
}