Track List

Numbered rows with animated equalizer bars.

Installation

npx shadcn@latest add https://cupertino-ui.baltoon.jp/r/track-list.json

Requires the theme tokens — see Installation if this is your first component.

Source

"use client";

import * as React from "react";
import { EllipsisIcon } from "lucide-react";

import { cn } from "@/lib/utils";

/**
 * The Apple Music track list: numbered rows with hairline
 * separators; the current track swaps its number for animated
 * equalizer bars.
 */
function EqualizerBars({ paused }: { paused?: boolean }) {
  return (
    <span
      aria-hidden
      className="flex h-3.5 items-end gap-[2px] text-red"
      data-slot="equalizer-bars"
    >
      <style>{`@keyframes cupertino-ui-eq { 0%,100% { height: 30% } 50% { height: 100% } }`}</style>
      {[0, 1, 2, 3].map((i) => (
        <span
          key={i}
          className="w-[3px] rounded-full bg-current"
          style={{
            height: "40%",
            animation: paused
              ? "none"
              : `cupertino-ui-eq ${0.7 + i * 0.13}s ease-in-out ${i * -0.19}s infinite`,
          }}
        />
      ))}
    </span>
  );
}

function TrackList({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="track-list"
      className={cn(
        "flex w-full flex-col [&>[data-slot=track-row]:not(:first-child)>[data-slot=track-row-inner]]:shadow-[0_-0.5px_0_0_var(--separator)]",
        className
      )}
      {...props}
    />
  );
}

function TrackRow({
  className,
  index,
  title,
  subtitle,
  artworkUrl,
  duration,
  active,
  playing,
  onMore,
  ...props
}: Omit<React.ComponentProps<"button">, "title"> & {
  /** 1-based track number (hidden while active). */
  index?: number;
  title: string;
  /** Artist line, for lists that mix albums. */
  subtitle?: string;
  /** Small artwork instead of a number (Songs list style). */
  artworkUrl?: string;
  /** Preformatted duration, e.g. "3:42". */
  duration?: string;
  /** This row is the current track. */
  active?: boolean;
  /** Whether audio is actually running (drives the bars). */
  playing?: boolean;
  /** Show the ellipsis button and call this when tapped. */
  onMore?: () => void;
}) {
  return (
    <button
      type="button"
      data-slot="track-row"
      className={cn(
        "group flex w-full cursor-default select-none items-center gap-3 pl-4 text-left outline-none focus-visible:bg-fill-quaternary active:bg-fill-tertiary",
        className
      )}
      {...props}
    >
      <span className="flex w-5 shrink-0 items-center justify-center">
        {active ? (
          <EqualizerBars paused={!playing} />
        ) : artworkUrl ? null : (
          <span className="text-subheadline tabular-nums text-secondary-label">
            {index}
          </span>
        )}
      </span>
      {artworkUrl ? (
        // eslint-disable-next-line @next/next/no-img-element
        <img
          src={artworkUrl}
          alt=""
          loading="lazy"
          className="-ml-2 size-11 shrink-0 rounded-[6px] object-cover shadow-[inset_0_0_0_0.5px_var(--separator)]"
        />
      ) : null}
      <span
        data-slot="track-row-inner"
        className="flex min-h-[44px] min-w-0 flex-1 items-center gap-3 py-2 pr-4"
      >
        <span className="flex min-w-0 flex-1 flex-col">
          <span className={cn("truncate text-body", active && "text-red")}>
            {title}
          </span>
          {subtitle ? (
            <span className="truncate text-footnote text-secondary-label">
              {subtitle}
            </span>
          ) : null}
        </span>
        {duration ? (
          <span className="shrink-0 text-footnote tabular-nums text-secondary-label">
            {duration}
          </span>
        ) : null}
        {onMore ? (
          <span
            role="button"
            tabIndex={-1}
            aria-label="More"
            onClick={(e) => {
              e.stopPropagation();
              onMore();
            }}
            className="flex size-7 shrink-0 items-center justify-center rounded-full text-secondary-label hover:bg-fill-quaternary"
          >
            <EllipsisIcon className="size-4" />
          </span>
        ) : null}
      </span>
    </button>
  );
}

export { EqualizerBars, TrackList, TrackRow };