A ranked list that reorders with layout motion when scores change, with per-row rank delta chips.
npx shadcn@latest add @paragon/leaderboard"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>
);
}