Sports

Leaderboard

A ranked list that reorders with layout motion when scores change, with per-row rank delta chips.

Install

npx shadcn@latest add @paragon/leaderboard

leaderboard.tsx

"use client";

import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { ArrowDown, ArrowUp, Minus } from "lucide-react";
import { cn } from "@/lib/utils";

export interface LeaderboardEntry {
  id: string;
  name: string;
  /** Value the rows are sorted by (descending). */
  value: number;
  /** Optional accent color for the avatar dot. */
  color?: string;
}

export interface LeaderboardProps extends React.ComponentProps<"ol"> {
  /** Entries in their current order — highest value first. */
  entries: LeaderboardEntry[];
  /** Formats the value column. */
  formatValue?: (value: number) => string;
  /** Renders reorders instantly, no layout animation. */
  static?: boolean;
}

/**
 * A ranked list that animates rows into their new position with motion's
 * layout engine whenever the order changes, and shows a per-row delta chip
 * for how many places each entry moved. Rank deltas are derived from the
 * previous order, so the chip appears only on the render where a row actually
 * moved. Reduced motion (or `static`) drops the layout animation but keeps the
 * delta chips.
 */
export function Leaderboard({
  entries,
  formatValue = (v) => v.toLocaleString("en-US"),
  static: isStatic = false,
  className,
  ...props
}: LeaderboardProps) {
  const reducedMotion = useReducedMotion();
  const animate = !isStatic && !reducedMotion;

  // Previous rank per id, to compute the movement delta for the chip.
  const prevRank = React.useRef<Map<string, number>>(new Map());
  const deltas = new Map<string, number>();
  entries.forEach((entry, index) => {
    const before = prevRank.current.get(entry.id);
    deltas.set(entry.id, before === undefined ? 0 : before - index);
  });
  React.useEffect(() => {
    const next = new Map<string, number>();
    entries.forEach((entry, index) => next.set(entry.id, index));
    prevRank.current = next;
  }, [entries]);

  return (
    <ol
      data-slot="leaderboard"
      className={cn(
        "flex w-full max-w-sm list-none flex-col gap-1 rounded-xl bg-card p-2 shadow-border",
        className,
      )}
      {...props}
    >
      {entries.map((entry, index) => (
        <motion.li
          key={entry.id}
          layout={animate ? "position" : false}
          transition={{ type: "spring", duration: 0.45, bounce: 0.1 }}
          className="flex items-center gap-3 rounded-lg px-3 py-2.5"
        >
          <span className="w-5 shrink-0 text-right text-sm font-semibold tabular-nums text-muted-foreground">
            {index + 1}
          </span>
          <span
            aria-hidden
            className="size-7 shrink-0 rounded-full"
            style={{ background: entry.color ?? "var(--muted-foreground)" }}
          />
          <span className="min-w-0 flex-1 truncate text-sm font-medium">
            {entry.name}
          </span>
          <DeltaChip delta={deltas.get(entry.id) ?? 0} />
          <span className="shrink-0 text-right text-sm font-semibold tabular-nums whitespace-nowrap">
            {formatValue(entry.value)}
          </span>
        </motion.li>
      ))}
    </ol>
  );
}

function DeltaChip({ delta }: { delta: number }) {
  if (delta === 0) {
    return (
      <span
        aria-hidden
        className="inline-flex w-9 items-center justify-center text-muted-foreground/40"
      >
        <Minus className="size-3" />
      </span>
    );
  }
  const up = delta > 0;
  return (
    <span
      className={cn(
        "inline-flex w-9 items-center justify-center gap-0.5 rounded-md px-1 py-0.5 text-xs font-medium tabular-nums",
        up
          ? "bg-success/10 text-success"
          : "bg-destructive/10 text-destructive",
      )}
    >
      {up ? (
        <ArrowUp className="size-3" />
      ) : (
        <ArrowDown className="size-3" />
      )}
      {Math.abs(delta)}
      <span className="sr-only">{up ? "up" : "down"} {Math.abs(delta)}</span>
    </span>
  );
}