An XP leaderboard with a league tier header, promotion and demotion zones, per-row rank movement indicators, and streak flames.
npx shadcn@latest add @paragon/xp-leaderboard"use client";
import * as React from "react";
import { ChevronUp, ChevronDown, Minus, Flame } from "lucide-react";
import { cn } from "@/lib/utils";
export interface XpEntry {
name: string;
xp: number;
/** Change in rank since last period. Positive = moved up. */
move?: number;
/** Current streak in days. */
streak?: number;
you?: boolean;
}
export interface XpLeaderboardProps extends React.ComponentProps<"div"> {
league?: string;
/** Number of top entries promoted (highlighted zone). */
promote?: number;
/** Number of bottom entries at risk of demotion. */
demote?: number;
entries?: XpEntry[];
}
const DEFAULT_ENTRIES: XpEntry[] = [
{ name: "María Fernández", xp: 4820, move: 2, streak: 41 },
{ name: "Kenji Watanabe", xp: 4610, move: 0, streak: 28 },
{ name: "Priya Nair", xp: 4390, move: 1, streak: 63 },
{ name: "You", xp: 3980, move: 4, streak: 12, you: true },
{ name: "Lucas Silva", xp: 3720, move: -2, streak: 8 },
{ name: "Amara Okonkwo", xp: 3510, move: -1, streak: 19 },
{ name: "Tom Becker", xp: 2940, move: -3, streak: 3 },
];
const LEAGUE_TINT: Record<string, string> = {
Bronze: "#cd7f32",
Silver: "#9ca3af",
Gold: "#eab308",
Sapphire: "#3b82f6",
Diamond: "#22d3ee",
};
/**
* An XP leaderboard with a league tier header, promotion/demotion zones, and
* per-row rank-movement indicators (up/down/flat) plus streak flames. Ranks are
* computed from XP so the list stays consistent; the viewer's row is pinned
* visually. All figures use tabular numerals.
*/
export function XpLeaderboard({
league = "Sapphire",
promote = 3,
demote = 2,
entries = DEFAULT_ENTRIES,
className,
...props
}: XpLeaderboardProps) {
const ranked = [...entries].sort((a, b) => b.xp - a.xp);
const tint = LEAGUE_TINT[league] ?? "#3b82f6";
return (
<div
data-slot="xp-leaderboard"
className={cn(
"w-full max-w-sm overflow-hidden rounded-xl bg-card text-card-foreground shadow-border",
className,
)}
{...props}
>
<div
className="flex items-center justify-between px-5 py-4"
style={{
background: `linear-gradient(180deg, color-mix(in oklch, ${tint} 14%, transparent), transparent)`,
}}
>
<div className="flex items-center gap-2.5">
<span
className="flex size-9 items-center justify-center rounded-full text-sm font-bold text-white"
style={{ background: tint }}
>
{league[0]}
</span>
<div>
<h3 className="text-sm font-semibold leading-tight">
{league} League
</h3>
<p className="text-xs text-muted-foreground">
Top {promote} promote · ends in 2 days
</p>
</div>
</div>
</div>
<ul className="px-2 pb-2">
{ranked.map((e, i) => {
const rank = i + 1;
const inPromote = i < promote;
const inDemote = i >= ranked.length - demote;
const move = e.move ?? 0;
const MoveIcon =
move > 0 ? ChevronUp : move < 0 ? ChevronDown : Minus;
return (
<li key={e.name}>
{i === promote && (
<div className="flex items-center gap-2 px-3 py-1.5">
<span className="h-px flex-1 bg-success/40" />
<span className="text-[10px] font-medium uppercase tracking-wide text-success">
Promotion zone
</span>
<span className="h-px flex-1 bg-success/40" />
</div>
)}
{i === ranked.length - demote && demote > 0 && (
<div className="flex items-center gap-2 px-3 py-1.5">
<span className="h-px flex-1 bg-destructive/40" />
<span className="text-[10px] font-medium uppercase tracking-wide text-destructive">
Demotion zone
</span>
<span className="h-px flex-1 bg-destructive/40" />
</div>
)}
<div
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2",
e.you && "bg-secondary/70 ring-1 ring-border",
)}
>
<span
className={cn(
"w-5 text-center text-sm font-semibold tabular-nums",
inPromote && "text-success",
inDemote && "text-destructive",
!inPromote && !inDemote && "text-muted-foreground",
)}
>
{rank}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{e.name}
</span>
{e.streak != null && e.streak > 0 && (
<span className="flex items-center gap-0.5 text-xs text-muted-foreground tabular-nums">
<Flame className="size-3 text-warning" />
{e.streak}
</span>
)}
<MoveIcon
className={cn(
"size-3.5",
move > 0 && "text-success",
move < 0 && "text-destructive",
move === 0 && "text-muted-foreground",
)}
/>
<span className="w-14 text-right text-sm font-semibold tabular-nums">
{e.xp.toLocaleString("en-US")}
</span>
</div>
</li>
);
})}
</ul>
</div>
);
}