{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "leaderboard",
  "type": "registry:ui",
  "title": "Leaderboard",
  "description": "Ranked rows that spring into place, roll their scores and pin you.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/leaderboard.tsx",
      "content": "\"use client\";\nimport NumberFlow, { type Format } from \"@number-flow/react\";\nimport { AnimatePresence, LayoutGroup, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useMemo, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\nconst noop = () => () => {};\nfunction useLocale(locale?: string) {\n  const detected = useSyncExternalStore(noop, () => Intl.NumberFormat().resolvedOptions().locale, () => \"en-US\");\n  return locale ?? detected;\n}\n\nexport type LeaderboardEntry = {\n  id: string;\n  name: string;\n  score: number;\n  /** A second line: a team, a region, a handle. */\n  meta?: string;\n  /** Image URL. Initials are drawn when it's missing or fails. */\n  avatar?: string;\n};\n\ntype Ranked = LeaderboardEntry & { rank: number };\n\n/** Highest score first. Ties share a rank and the next rank is skipped: 1, 2, 2, 4. */\nexport function rankEntries(entries: LeaderboardEntry[]): Ranked[] {\n  const sorted = [...entries].sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));\n  let rank = 0;\n  return sorted.map((e, i) => {\n    if (i === 0 || e.score !== sorted[i - 1].score) rank = i + 1;\n    return { ...e, rank };\n  });\n}\n\nexport type LeaderboardProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  entries: LeaderboardEntry[];\n  /** The viewer's entry. Highlighted, and pinned below the list when it falls outside the limit. */\n  youId?: string;\n  /** How many rows to show before the pinned \"you\" row. */\n  limit?: number;\n  /** Names the list for screen readers. */\n  label?: string;\n  /** Intl options for scores, e.g. { style: \"currency\", currency: \"USD\", notation: \"compact\" }. */\n  format?: Format;\n  suffix?: string;\n  locale?: string;\n  /** Header for the score column. */\n  scoreLabel?: string;\n  loading?: boolean;\n  emptyLabel?: string;\n};\n\nexport function Leaderboard({\n  entries,\n  youId,\n  limit = 8,\n  label = \"Leaderboard\",\n  format,\n  suffix,\n  locale: localeProp,\n  scoreLabel = \"Score\",\n  loading = false,\n  emptyLabel = \"No scores yet\",\n  className,\n  ...rest\n}: LeaderboardProps) {\n  const locale = useLocale(localeProp);\n  const reduce = useReducedMotion();\n  const ranked = useMemo(() => rankEntries(entries), [entries]);\n\n  // Movement since the last update, derived while rendering from the previous ranking.\n  const [prev, setPrev] = useState<{ entries: LeaderboardEntry[]; ranks: Map<string, number>; moves: Map<string, number>; tick: number }>(() => ({\n    entries,\n    ranks: new Map(ranked.map((r) => [r.id, r.rank])),\n    moves: new Map(),\n    tick: 0,\n  }));\n  if (prev.entries !== entries) {\n    const moves = new Map<string, number>();\n    for (const r of ranked) {\n      const before = prev.ranks.get(r.id);\n      if (before != null && before !== r.rank) moves.set(r.id, before - r.rank);\n    }\n    setPrev({ entries, ranks: new Map(ranked.map((r) => [r.id, r.rank])), moves: moves.size ? moves : prev.moves, tick: moves.size ? prev.tick + 1 : prev.tick });\n  }\n\n  // Arrows describe the latest change only; they clear after a few seconds of calm.\n  useEffect(() => {\n    if (!prev.moves.size) return;\n    const t = window.setTimeout(() => setPrev((p) => ({ ...p, moves: new Map() })), 4000);\n    return () => window.clearTimeout(t);\n  }, [prev.tick, prev.moves.size]);\n\n  const you = youId ? ranked.find((r) => r.id === youId) : undefined;\n  const pinned = you && !ranked.slice(0, limit).some((r) => r.id === you.id) ? you : undefined;\n  // The board is always `limit` rows tall: when you're pinned, you take the last slot, so nothing jumps as you climb in or fall out.\n  const top = ranked.slice(0, pinned ? limit - 1 : limit);\n  const ordinal = useMemo(() => new Intl.PluralRules(locale, { type: \"ordinal\" }), [locale]);\n  const suffixes: Record<string, string> = { one: \"st\", two: \"nd\", few: \"rd\", other: \"th\" };\n  const nth = (n: number) => (locale.startsWith(\"en\") ? `${n}${suffixes[ordinal.select(n)] ?? \"th\"}` : `#${n}`);\n  const youMove = you ? prev.moves.get(you.id) : undefined;\n\n  const row = (r: Ranked) => {\n    const isYou = r.id === youId;\n    const move = prev.moves.get(r.id);\n    return (\n      <motion.li\n        key={r.id}\n        layout={reduce ? false : \"position\"}\n        // One layoutId per person, so the \"you\" row flies between the pinned slot and the list.\n        layoutId={`row-${r.id}`}\n        transition={{ layout: spring.soft }}\n        initial={false}\n        data-you={isYou || undefined}\n        // The climber passes over the rows it overtakes.\n        style={{ zIndex: move != null && move > 0 ? 1 : 0 }}\n        className={cn(\n          \"relative grid h-11 grid-cols-[48px_minmax(0,1fr)_auto] items-center gap-2.5 rounded-lg px-2.5\",\n          // Rows are opaque so two crossing mid-swap never show through each other; set --leaderboard-surface to match your card.\n          isYou ? \"bg-hover\" : \"bg-[var(--leaderboard-surface,var(--raised))]\",\n        )}\n      >\n        {/* A mover gets a brief wash in the color of its direction, keyed so it replays on every move. */}\n        {move != null && (\n          <motion.span\n            key={`${prev.tick}`}\n            aria-hidden\n            className={cn(\"pointer-events-none absolute inset-0 rounded-lg\", move > 0 ? \"bg-success-soft\" : \"bg-danger-soft\")}\n            initial={{ opacity: 1 }}\n            animate={{ opacity: 0 }}\n            transition={{ duration: 1.4, ease: ease.outQuart, delay: 0.2 }}\n          />\n        )}\n        {/* Rank and its movement share one column, so a name never gives up room to an arrow. */}\n        <span className=\"relative flex items-center gap-1\">\n          <span className={cn(\"min-w-[1.25rem] font-mono text-[12px] tabular\", r.rank <= 3 ? \"text-fg\" : \"text-fg-3\")}>\n            <NumberFlow value={r.rank} animated={!reduce} locales={locale} />\n          </span>\n          <span className=\"relative grid w-6 place-items-start\">\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              {move != null && (\n                <motion.span\n                  key={`${prev.tick}-${move}`}\n                  className={cn(\"flex items-center gap-0.5 text-[11px] tabular\", move > 0 ? \"text-success\" : \"text-danger\")}\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, y: move > 0 ? 6 : -6, scale: 0.8 }}\n                  animate={{ opacity: 1, y: 0, scale: 1 }}\n                  exit={{ opacity: 0, transition: { duration: 0.12 } }}\n                  transition={reduce ? { duration: 0.15 } : spring.pop}\n                >\n                  <svg width=\"8\" height=\"8\" viewBox=\"0 0 8 8\" aria-hidden className={move < 0 ? \"rotate-180\" : \"\"}>\n                    <path d=\"M4 1.5 7 6H1z\" fill=\"currentColor\" />\n                  </svg>\n                  {Math.abs(move)}\n                  <span className=\"sr-only\">{move > 0 ? \" places up\" : \" places down\"}</span>\n                </motion.span>\n              )}\n            </AnimatePresence>\n          </span>\n        </span>\n\n        <span className=\"relative flex min-w-0 items-center gap-2.5\">\n          <Avatar name={r.name} src={r.avatar} />\n          <span className=\"flex min-w-0 flex-col\">\n            <span className=\"truncate text-[13px] font-medium tracking-[-0.005em] text-fg\">{r.name}</span>\n            {/* The \"You\" tag rides on the second line, so the name keeps its full width. */}\n            {(r.meta || isYou) && (\n              <span className=\"flex min-w-0 items-center gap-1.5 text-[11.5px] text-fg-3\">\n                {isYou && <span className=\"shrink-0 rounded-[4px] border border-line-2 px-1 text-2xs leading-[14px] text-fg-2\">You</span>}\n                {r.meta && <span className=\"truncate\">{r.meta}</span>}\n              </span>\n            )}\n          </span>\n        </span>\n\n        <span className=\"relative flex items-center justify-end\">\n          <NumberFlow\n            value={r.score}\n            format={format}\n            suffix={suffix}\n            locales={locale}\n            animated={!reduce}\n            className=\"text-right text-[13px] text-fg tabular\"\n          />\n        </span>\n      </motion.li>\n    );\n  };\n\n  return (\n    <div data-slot=\"leaderboard\" aria-busy={loading || undefined} className={cn(\"flex w-full min-w-0 flex-col\", className)} {...rest}>\n      <div aria-hidden className=\"grid grid-cols-[48px_minmax(0,1fr)_auto] gap-2.5 px-2.5 pb-1.5 font-mono text-2xs uppercase tracking-[0.08em] text-fg-4\">\n        <span>#</span>\n        <span>Name</span>\n        <span>{scoreLabel}</span>\n      </div>\n\n      {loading && !entries.length ? (\n        <ul aria-label={`${label}, loading`} className=\"flex flex-col\">\n          {Array.from({ length: Math.min(limit, 5) }, (_, i) => (\n            <li key={i} className=\"grid h-11 grid-cols-[48px_minmax(0,1fr)_auto] items-center gap-2.5 px-2.5\">\n              <span className=\"h-2.5 w-3 rounded bg-fg/[0.07]\" />\n              <span className=\"flex items-center gap-2.5\">\n                <span className=\"size-7 rounded-full bg-fg/[0.07] motion-safe:animate-pulse-soft\" />\n                <span className=\"h-2.5 rounded bg-fg/[0.07] motion-safe:animate-pulse-soft\" style={{ width: 70 + ((i * 41) % 60) }} />\n              </span>\n              <span className=\"h-2.5 w-12 rounded bg-fg/[0.07] motion-safe:animate-pulse-soft\" />\n            </li>\n          ))}\n        </ul>\n      ) : !ranked.length ? (\n        <p role=\"status\" className=\"grid h-24 place-items-center text-[12.5px] text-fg-3\">\n          {emptyLabel}\n        </p>\n      ) : (\n        <LayoutGroup>\n          <ol aria-label={label} className={cn(\"flex flex-col transition-opacity duration-200\", loading && \"opacity-50\")}>\n            {top.map((r) => row(r))}\n          </ol>\n          {pinned && (\n            <ol\n              aria-label=\"Your position\"\n              start={pinned.rank}\n              className=\"relative flex flex-col before:absolute before:inset-x-2.5 before:top-0 before:h-px before:bg-line before:content-['']\"\n            >\n              {row(pinned)}\n            </ol>\n          )}\n        </LayoutGroup>\n      )}\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {you && youMove != null ? `You moved ${youMove > 0 ? \"up\" : \"down\"} to ${nth(you.rank)}` : \"\"}\n      </span>\n    </div>\n  );\n}\n\nfunction Avatar({ name, src }: { name: string; src?: string }) {\n  const [failed, setFailed] = useState(false);\n  const initials = name\n    .split(/\\s+/)\n    .map((p) => p[0])\n    .slice(0, 2)\n    .join(\"\")\n    .toUpperCase();\n  return (\n    <span className=\"relative grid size-7 shrink-0 place-items-center overflow-hidden rounded-full bg-fg/[0.07] text-[10.5px] font-medium text-fg-2\">\n      {src && !failed ? (\n        // eslint-disable-next-line @next/next/no-img-element\n        <img src={src} alt=\"\" width={28} height={28} className=\"size-full object-cover\" onError={() => setFailed(true)} />\n      ) : (\n        <span aria-hidden>{initials}</span>\n      )}\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/leaderboard"
}
