EdTech

XP Leaderboard

An XP leaderboard with a league tier header, promotion and demotion zones, per-row rank movement indicators, and streak flames.

Install

npx shadcn@latest add @paragon/xp-leaderboard

xp-leaderboard.tsx

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