Skip to content

Market Leaderboard

A podium and ranked list by profit, volume or accuracy.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/market-leaderboard.json

Manual

  1. Install the dependencies

    npm install clsx motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/markets/avatar.tsx
    import { cn } from "@/lib/utils";
    
    /** A generated avatar: a gradient from a hash of the name, with initials. No images needed. */
    export function MarketAvatar({ name, className }: { name: string; className?: string }) {
      let hash = 0;
      for (let i = 0; i < name.length; i++) hash = (Math.imul(hash, 31) + name.charCodeAt(i)) | 0;
      const hue = Math.abs(hash) % 360;
      const initials = name
        .replace(/^@/, "")
        .split(/[\s._-]+/)
        .map((part) => part[0])
        .join("")
        .slice(0, 2)
        .toUpperCase();
      return (
        <span
          aria-hidden="true"
          className={cn("grid size-7 shrink-0 place-items-center rounded-full text-[10px] font-semibold text-white ring-2 ring-background", className)}
          style={{ background: `linear-gradient(135deg, hsl(${hue} 70% 55%), hsl(${(hue + 40) % 360} 70% 40%))` }}
        >
          {initials}
        </span>
      );
    }
    
    /** A small "Simulated" badge for demos that use mock data. */
    export function SimulatedBadge({ className }: { className?: string }) {
      return (
        <span className={cn("inline-flex h-5 items-center gap-1 rounded-full border border-warning/30 bg-warning/10 px-2 text-[11px] font-medium text-[color-mix(in_oklab,var(--warning)_75%,var(--foreground))]", className)}>
          <span aria-hidden="true" className="size-1.5 rounded-full bg-warning" />
          Simulated
        </span>
      );
    }
    lib/markets/format.ts
    import { FULL } from "@/lib/markets/math";
    
    const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
    // minimumFractionDigits: 0 matters: currency defaults to 2, and Node and browsers clamp it differently ("$412.0K" vs "$412K").
    const usdCompact = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", notation: "compact", minimumFractionDigits: 0, maximumFractionDigits: 1 });
    
    /** "64%" (or "64.2%" with decimals). */
    export const formatProbability = (bps: number, decimals = 0) => `${(bps / 100).toFixed(decimals)}%`;
    
    /** "64¢". */
    export const formatPrice = (bps: number) => `${Math.round(bps / 100)}¢`;
    
    /** "$1,234.56", or "$1.2M" compact. Input in cents. */
    export const formatUsd = (cents: number, compact = false) => (compact ? usdCompact.format(cents / 100) : usd.format(cents / 100));
    
    /** Shares from hundredths: "156.25". */
    export const formatShares = (hundredths: number) => (hundredths / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
    
    /** "▲ 4%" / "▼ 2.5%" from a basis-point change (never color alone: the arrow carries the direction). */
    export function formatChange(bps: number) {
      const value = Math.abs(bps) / 100;
      return `${bps >= 0 ? "▲" : "▼"} ${value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)}%`;
    }
    
    /** Signed percent from basis points: "+56%". */
    export const formatReturn = (bps: number) => `${bps >= 0 ? "+" : "−"}${(Math.abs(bps) / 100).toFixed(bps % 100 === 0 ? 0 : 1)}%`;
    
    /** "Ends in 3d 4h", "Ends in 42m" or "Ended". */
    export function timeLeft(endsAt: number, now: number) {
      const ms = endsAt - now;
      if (ms <= 0) return "Ended";
      const minutes = Math.floor(ms / 60000);
      const days = Math.floor(minutes / 1440);
      const hours = Math.floor((minutes % 1440) / 60);
      if (days > 0) return `Ends in ${days}d ${hours}h`;
      if (hours > 0) return `Ends in ${hours}h ${minutes % 60}m`;
      return `Ends in ${Math.max(1, minutes)}m`;
    }
    
    /** A fixed, locale-stable date: "Mar 14, 2027". */
    export const formatDate = (ms: number) => new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }).format(ms);
    
    export { FULL };
    lib/markets/math.ts
    /**
     * Prediction market math on integers only: prices in basis points (10000 = $1.00 = 100%), money in
     * cents and shares in hundredths. A share pays $1 (100 cents) if its outcome wins.
     */
    export const FULL = 10000;
    
    const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
    
    /** The other side of a binary price: Yes + No = 100¢. */
    export const complement = (price: number) => FULL - price;
    
    /** Shares (in hundredths) that `amount` cents buys at `price` basis points. */
    export const sharesFor = (amount: number, price: number) => (price > 0 ? Math.floor((amount * FULL) / price) : 0);
    
    /** Payout in cents if those shares win: each share pays 100 cents, so hundredths of a share map 1:1 to cents. */
    export const payoutFor = (shares: number) => shares;
    
    /** Value in cents of shares (hundredths) at a price. */
    export const valueOf = (shares: number, price: number) => Math.floor((shares * price) / FULL);
    
    export interface BuyQuote {
      /** Average fill price, basis points. */
      averagePrice: number;
      /** Price after the trade, basis points. */
      newPrice: number;
      /** Price impact, basis points (newPrice − price). */
      impact: number;
      /** Shares received, hundredths. */
      shares: number;
      /** Payout if it wins, cents. */
      payout: number;
      /** Return if it wins, basis points of the amount (5600 = +56%). */
      returnBps: number;
    }
    
    /**
     * A simulated buy against a constant-product style curve: the more of the pool you take, the further
     * the price moves toward $1. Good enough to show impact and average price in a demo.
     */
    export function quoteBuy(price: number, amount: number, liquidity: number): BuyQuote {
      const p = clamp(Math.round(price), 1, FULL - 1);
      if (amount <= 0) return { averagePrice: p, newPrice: p, impact: 0, shares: 0, payout: 0, returnBps: 0 };
      const take = Math.floor((amount * FULL) / (amount + Math.max(1, liquidity)));
      const newPrice = clamp(p + Math.floor(((FULL - p) * take) / FULL), 1, FULL - 1);
      const averagePrice = Math.floor((p + newPrice) / 2);
      const shares = sharesFor(amount, averagePrice);
      const payout = payoutFor(shares);
      return { averagePrice, newPrice, impact: newPrice - p, shares, payout, returnBps: Math.floor(((payout - amount) * FULL) / amount) };
    }
    
    /** A simulated limit buy: fills entirely at the limit price, no impact. */
    export function quoteLimit(limitPrice: number, amount: number): BuyQuote {
      const p = clamp(Math.round(limitPrice), 1, FULL - 1);
      const shares = sharesFor(Math.max(0, amount), p);
      return { averagePrice: p, newPrice: p, impact: 0, shares, payout: shares, returnBps: amount > 0 ? Math.floor(((shares - amount) * FULL) / amount) : 0 };
    }
    
    /** Proceeds in cents from selling shares (hundredths), with the price sliding toward 0 by the same curve. */
    export function quoteSell(price: number, shares: number, liquidity: number) {
      const p = clamp(Math.round(price), 1, FULL - 1);
      const gross = valueOf(shares, p);
      const take = Math.floor((gross * FULL) / (gross + Math.max(1, liquidity)));
      const newPrice = clamp(p - Math.floor((p * take) / FULL), 1, FULL - 1);
      const averagePrice = Math.floor((p + newPrice) / 2);
      return { proceeds: valueOf(shares, averagePrice), averagePrice, newPrice, impact: newPrice - p };
    }
    
    /** Profit and loss in cents for shares bought at an average price, marked at the current price. */
    export function profitAndLoss(shares: number, averagePrice: number, currentPrice: number) {
      const cost = valueOf(shares, averagePrice);
      const value = valueOf(shares, currentPrice);
      return { cost, value, pnl: value - cost, pnlBps: cost ? Math.floor(((value - cost) * FULL) / cost) : 0 };
    }
    
    /** Scales positive weights so they sum to exactly 10000 (rounding error goes to the largest). */
    export function normalize(prices: number[]) {
      const total = prices.reduce((sum, p) => sum + Math.max(0, p), 0) || 1;
      const scaled = prices.map((p) => Math.floor((Math.max(0, p) * FULL) / total));
      const rest = FULL - scaled.reduce((sum, p) => sum + p, 0);
      const largest = scaled.indexOf(Math.max(...scaled));
      if (largest >= 0) scaled[largest] += rest;
      return scaled;
    }
  3. Add the theme tokens

    This component uses --success and --warning, which shadcn does not define by default. Add them to your global CSS:

    app/globals.css
    :root {
      --success: #15803d;
      --warning: #b45309;
    }
    
    .dark {
      --success: #4ade80;
      --warning: #fbbf24;
    }
    
    @theme inline {
      --color-success: var(--success);
      --color-warning: var(--warning);
    }
  4. Copy the source

    components/ui/market-leaderboard/market-leaderboard.tsx
    "use client";
    
    import { useId, useState } from "react";
    import { LayoutGroup, motion, useReducedMotion } from "motion/react";
    import { cn } from "@/lib/utils";
    import { MarketAvatar } from "@/lib/markets/avatar";
    import { formatProbability, formatUsd } from "@/lib/markets/format";
    
    export interface LeaderEntry {
      trader: string;
      /** Profit, cents (can be negative). */
      profit: number;
      /** Volume traded, cents. */
      volume: number;
      /** Share of correct predictions, basis points. */
      accuracy: number;
      /** Rank change since the previous period (positive = moved up). */
      change: number;
    }
    
    export type LeaderPeriod = "week" | "month" | "all";
    export type LeaderMetric = "profit" | "volume" | "accuracy";
    
    export interface MarketLeaderboardProps {
      /** Entries per period. Simulated data only. */
      data: Record<LeaderPeriod, LeaderEntry[]>;
      /** The viewer's trader name; their row is pinned at the bottom and highlighted. */
      currentUser?: string;
      /** Rows listed under the podium. @default 7 */
      limit?: number;
      /** Classes for the panel. */
      className?: string;
    }
    
    const PERIODS: { id: LeaderPeriod; label: string }[] = [
      { id: "week", label: "This week" },
      { id: "month", label: "This month" },
      { id: "all", label: "All time" },
    ];
    const METRICS: { id: LeaderMetric; label: string }[] = [
      { id: "profit", label: "Profit" },
      { id: "volume", label: "Volume" },
      { id: "accuracy", label: "Accuracy" },
    ];
    
    /** Arrow keys, Home and End move between tabs (roving tabindex). */
    function onTabKey(event: React.KeyboardEvent<HTMLDivElement>) {
      if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
      const tabs = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="tab"]'));
      const i = tabs.findIndex((t) => t === document.activeElement);
      const next = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : (i + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length;
      event.preventDefault();
      tabs[next]?.focus();
      tabs[next]?.click();
    }
    
    const valueOf = (entry: LeaderEntry, metric: LeaderMetric) => (metric === "profit" ? entry.profit : metric === "volume" ? entry.volume : entry.accuracy);
    const show = (entry: LeaderEntry, metric: LeaderMetric) => (metric === "accuracy" ? formatProbability(entry.accuracy) : `${metric === "profit" && entry.profit > 0 ? "+" : ""}${formatUsd(valueOf(entry, metric), true)}`);
    
    function Change({ change }: { change: number }) {
      if (!change) return <span className="text-[11px] text-muted-foreground">–</span>;
      return (
        <span className={cn("text-[11px] font-medium tabular-nums", change > 0 ? "text-[color-mix(in_oklab,var(--success)_75%,var(--foreground))]" : "text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]")}>
          {change > 0 ? "▲" : "▼"}
          {Math.abs(change)}
          <span className="sr-only">{change > 0 ? " places up" : " places down"}</span>
        </span>
      );
    }
    
    /**
     * Top predictors by period and metric: a podium for the top three that rises in, a ranked list that
     * re-sorts with layout animation when the tab or metric changes, and the viewer's row pinned below.
     */
    export function MarketLeaderboard({ data, currentUser, limit = 7, className }: MarketLeaderboardProps) {
      const id = useId();
      const reduceMotion = useReducedMotion();
      const [period, setPeriod] = useState<LeaderPeriod>("week");
      const [metric, setMetric] = useState<LeaderMetric>("profit");
      const ranked = [...data[period]].sort((a, b) => valueOf(b, metric) - valueOf(a, metric));
      const podium = ranked.slice(0, 3);
      const rest = ranked.slice(3, 3 + limit);
      const meIndex = currentUser ? ranked.findIndex((e) => e.trader === currentUser) : -1;
      const spring = reduceMotion ? { duration: 0 } : { type: "spring" as const, stiffness: 420, damping: 36 };
    
      return (
        <section aria-label="Leaderboard" className={cn("w-full rounded-2xl border border-border bg-background p-4", className)}>
          <LayoutGroup id={`${id}-p`}>
            <div role="tablist" aria-label="Period" onKeyDown={onTabKey} className="grid grid-cols-3 gap-0.5 rounded-full bg-surface-2 p-0.5">
              {PERIODS.map((p) => (
                <button key={p.id} type="button" role="tab" id={`${id}-tab-${p.id}`} aria-controls={`${id}-panel`} aria-selected={period === p.id} tabIndex={period === p.id ? 0 : -1} onClick={() => setPeriod(p.id)} className={cn("relative h-8 cursor-pointer rounded-full text-[12px] font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", period === p.id ? "text-foreground" : "text-[color-mix(in_oklab,var(--muted-foreground)_75%,var(--foreground))]")}>
                  {period === p.id ? <motion.span layoutId="lb-period" transition={spring} className="absolute inset-0 rounded-full bg-background shadow-sm" /> : null}
                  <span className="relative">{p.label}</span>
                </button>
              ))}
            </div>
          </LayoutGroup>
          <div role="tabpanel" id={`${id}-panel`} aria-labelledby={`${id}-tab-${period}`}>
          <div role="radiogroup" aria-label="Rank by" className="mt-2 flex justify-center gap-1">
            {METRICS.map((m) => (
              <button key={m.id} type="button" role="radio" aria-checked={metric === m.id} onClick={() => setMetric(m.id)} className={cn("h-7 cursor-pointer rounded-full px-2.5 text-[12px] outline-none focus-visible:ring-2 focus-visible:ring-ring", metric === m.id ? "bg-foreground text-background" : "text-muted-foreground hover:text-foreground")}>
                {m.label}
              </button>
            ))}
          </div>
          <ol aria-label="Top three" className="mt-4 grid grid-cols-3 items-end gap-2">
            {[1, 0, 2].map((slot, column) => {
              const entry = podium[slot];
              if (!entry) return <li key={slot} />;
              const heights = ["h-20", "h-14", "h-10"];
              return (
                <motion.li
                  key={`${period}-${metric}-${entry.trader}`}
                  initial={reduceMotion ? false : { opacity: 0, y: 24 }}
                  animate={{ opacity: 1, y: 0 }}
                  transition={{ type: "spring", stiffness: 300, damping: 26, delay: reduceMotion ? 0 : column * 0.06 }}
                  className="flex flex-col items-center gap-1.5"
                >
                  <MarketAvatar name={entry.trader} className={cn(slot === 0 ? "size-12 text-sm" : "size-10 text-xs", entry.trader === currentUser && "ring-2 ring-primary ring-offset-2 ring-offset-background")} />
                  <p className="max-w-full truncate text-[12px] font-medium text-foreground">
                    {entry.trader}
                    {entry.trader === currentUser ? <span className="text-muted-foreground"> (you)</span> : null}
                  </p>
                  <p className="text-[12px] text-muted-foreground tabular-nums">{show(entry, metric)}</p>
                  <div className={cn("flex w-full items-start justify-center rounded-t-xl pt-1.5 text-sm font-bold tabular-nums", heights[slot], slot === 0 ? "bg-[#F2C14E]/30 text-[color-mix(in_oklab,#B8860B_45%,var(--foreground))]" : slot === 1 ? "bg-foreground/15 text-[color-mix(in_oklab,#71717a_45%,var(--foreground))]" : "bg-[#C8834B]/25 text-[color-mix(in_oklab,#A0522D_45%,var(--foreground))]")}>
                    <span className="sr-only">Rank </span>
                    {slot + 1}
                  </div>
                </motion.li>
              );
            })}
          </ol>
          <LayoutGroup id={`${id}-l`}>
            <ol start={4} className="mt-2 divide-y divide-border">
              {rest.map((entry, i) => (
                <motion.li key={entry.trader} layout={reduceMotion ? false : "position"} transition={spring} className={cn("flex items-center gap-3 py-2", entry.trader === currentUser && "rounded-lg bg-primary/6 px-2")}>
                  <span className="w-5 text-[12px] text-muted-foreground tabular-nums">{i + 4}</span>
                  <MarketAvatar name={entry.trader} className="size-7" />
                  <span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">
                    {entry.trader}
                    {entry.trader === currentUser ? <span className="font-normal text-muted-foreground"> (you)</span> : null}
                  </span>
                  <Change change={entry.change} />
                  <span className="w-20 text-right text-[13px] font-medium text-foreground tabular-nums">{show(entry, metric)}</span>
                </motion.li>
              ))}
            </ol>
          </LayoutGroup>
          {meIndex >= 0 && meIndex >= 3 + limit ? (
            <div className="mt-2 flex items-center gap-3 rounded-lg border border-primary/30 bg-primary/6 px-2 py-2">
              <span className="w-5 text-[12px] text-muted-foreground tabular-nums">{meIndex + 1}</span>
              <MarketAvatar name={ranked[meIndex].trader} className="size-7" />
              <span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">{ranked[meIndex].trader} (you)</span>
              <Change change={ranked[meIndex].change} />
              <span className="w-20 text-right text-[13px] font-medium text-foreground tabular-nums">{show(ranked[meIndex], metric)}</span>
            </div>
          ) : null}
          </div>
        </section>
      );
    }

Usage

import { MarketLeaderboard } from "@/components/ui/market-leaderboard/market-leaderboard";

<MarketLeaderboard
  data={{ week, month, all }} // [{ trader, profit, volume, accuracy, change }]
  currentUser="@alibar"
/>

API reference

PropTypeDefaultDescription
data*Record<LeaderPeriod, LeaderEntry[]>–Entries per period. Simulated data only.
currentUserstring–The viewer's trader name; their row is pinned at the bottom and highlighted.
limitnumber7Rows listed under the podium.
classNamestring–Classes for the panel.
  • Profit and volume are cents, accuracy is basis points. Rows re-sort by the selected metric with a layout animation.
  • If the current user is outside the visible list, their row is pinned at the bottom with their real rank.
  • Leaderboard data in the demo is simulated.

Accessibility

  • The period is a tab list and the ranking metric is a radio group. Ranks are real ordered lists, and the podium includes a spoken "Rank" before each number.
  • Rank changes read "▲2 places up" / "▼1 places down", so direction never depends on color. The viewer's pinned row is labelled "(you)".
  • With reduced motion the podium rise and list re-sort are instant.