{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "leaderboard",
  "type": "registry:component",
  "title": "Leaderboard",
  "description": "A ranked list that reshuffles itself when scores land: rows glide to their new places, gains flash beside the rolling totals, and your own row stays pinned below with the gap to the next person.",
  "categories": [
    "component",
    "Numbers"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/devl/leaderboard.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { ArrowDownIcon, ArrowUpIcon, MinusIcon } from \"lucide-react\";\nimport { AnimatePresence, LayoutGroup, motion, useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useEffect, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface LeaderboardEntry {\n  id: string;\n  name: string;\n  detail?: string;\n  score: number;\n  previousRank?: number;\n}\n\nexport interface LeaderboardProps {\n  title?: string;\n  caption?: string;\n  entries: LeaderboardEntry[];\n  youId?: string;\n  visible?: number;\n  format?: Intl.NumberFormatOptions;\n  locale?: string;\n  action?: ReactNode;\n  className?: string;\n}\n\nconst DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];\nconst AVATAR_TONES = [\n  \"bg-violet-500/15 text-violet-700 dark:text-violet-300\",\n  \"bg-sky-500/15 text-sky-700 dark:text-sky-300\",\n  \"bg-emerald-500/15 text-emerald-700 dark:text-emerald-300\",\n  \"bg-amber-500/15 text-amber-800 dark:text-amber-300\",\n  \"bg-rose-500/15 text-rose-700 dark:text-rose-300\",\n  \"bg-teal-500/15 text-teal-700 dark:text-teal-300\",\n  \"bg-indigo-500/15 text-indigo-700 dark:text-indigo-300\",\n];\nconst PODIUM = [\n  \"bg-amber-400/20 text-amber-700 dark:text-amber-300\",\n  \"bg-neutral-400/20 text-neutral-600 dark:text-neutral-300\",\n  \"bg-orange-500/15 text-orange-700 dark:text-orange-300\",\n];\n\nfunction hash(s: string) {\n  let h = 0;\n  for (const c of s) h = (h * 31 + c.charCodeAt(0)) | 0;\n  return Math.abs(h);\n}\n\nfunction RollingNumber({ text, className }: { text: string; className?: string }) {\n  const reduce = useReducedMotion();\n  let d = 0;\n  let o = 0;\n  const glyphs = [...text]\n    .reverse()\n    .map((ch) => {\n      const isDigit = ch >= \"0\" && ch <= \"9\";\n      return { ch, key: isDigit ? `d${d++}` : `s${o++}${ch}`, digit: isDigit ? Number(ch) : null };\n    })\n    .reverse();\n  return (\n    <span className={cn(\"relative inline-flex whitespace-nowrap tabular-nums leading-none\", className)}>\n      <span className=\"sr-only\">{text}</span>\n      <span aria-hidden className=\"inline-flex [--row:1.15em]\">\n        {glyphs.map((g) =>\n          g.digit === null ? (\n            <span className=\"inline-block h-(--row) leading-(--row)\" key={g.key}>\n              {g.ch}\n            </span>\n          ) : (\n            <span\n              className=\"relative inline-block h-(--row) overflow-hidden\"\n              key={g.key}\n              style={{ maskImage: \"linear-gradient(transparent, black 18%, black 82%, transparent)\" }}\n            >\n              <span className=\"invisible block leading-(--row)\">0</span>\n              <motion.span\n                animate={{ y: `${-g.digit * 10}%` }}\n                className=\"absolute inset-x-0 top-0 flex flex-col items-center\"\n                initial={false}\n                transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 260, damping: 30 }}\n              >\n                {DIGITS.map((n) => (\n                  <span className=\"block h-(--row) leading-(--row)\" key={n}>\n                    {n}\n                  </span>\n                ))}\n              </motion.span>\n            </span>\n          ),\n        )}\n      </span>\n    </span>\n  );\n}\n\nfunction Movement({ rank, previous }: { rank: number; previous?: number }) {\n  const diff = previous === undefined ? 0 : previous - rank;\n  return (\n    <span\n      aria-label={diff === 0 ? \"No change\" : `${diff > 0 ? \"Up\" : \"Down\"} ${Math.abs(diff)}`}\n      role=\"img\"\n      className={cn(\n        \"inline-flex w-8 items-center gap-px text-[11px] tabular-nums @max-[24rem]:w-auto @max-[24rem]:text-[10px] @max-[24rem]:leading-3\",\n        diff > 0 && \"text-success-foreground\",\n        diff < 0 && \"text-destructive-foreground\",\n        diff === 0 && \"text-muted-foreground/60\",\n      )}\n    >\n      {diff > 0 ? <ArrowUpIcon className=\"size-3\" /> : diff < 0 ? <ArrowDownIcon className=\"size-3\" /> : <MinusIcon className=\"size-3\" />}\n      {diff !== 0 ? Math.abs(diff) : null}\n    </span>\n  );\n}\n\nfunction Row({\n  e,\n  rank,\n  isYou,\n  gain,\n  pinned = false,\n  ahead,\n  fmt,\n  compact,\n}: {\n  e: LeaderboardEntry;\n  rank: number;\n  isYou: boolean;\n  gain?: { amount: number; key: number };\n  pinned?: boolean;\n  ahead?: LeaderboardEntry;\n  fmt: Intl.NumberFormat;\n  compact: Intl.NumberFormat;\n}) {\n  const reduce = useReducedMotion();\n  const layoutSpring = reduce ? { duration: 0 } : { type: \"spring\" as const, stiffness: 420, damping: 38 };\n  return (\n    <motion.li\n      className={cn(\n        \"relative flex h-12 items-center gap-2.5 rounded-xl px-2.5\",\n        isYou && \"bg-info/8 ring-1 ring-info/20 ring-inset dark:bg-info/12\",\n      )}\n      animate={{ opacity: 1 }}\n      exit={{ opacity: 0, transition: { duration: 0.15 } }}\n      initial={{ opacity: 0 }}\n      layout=\"position\"\n      layoutId={`row-${e.id}`}\n      transition={{ layout: layoutSpring, opacity: { duration: 0.2 } }}\n    >\n      <span className=\"flex shrink-0 items-center gap-2.5 @max-[24rem]:w-6 @max-[24rem]:flex-col @max-[24rem]:gap-0\">\n        <span\n          className={cn(\n            \"flex size-6 shrink-0 items-center justify-center rounded-md font-medium text-xs tabular-nums\",\n            rank <= 3 ? PODIUM[rank - 1] : \"text-muted-foreground\",\n          )}\n        >\n          {rank}\n        </span>\n        <Movement previous={e.previousRank} rank={rank} />\n      </span>\n      <span\n        aria-hidden\n        className={cn(\n          \"flex size-8 shrink-0 items-center justify-center rounded-full font-medium text-xs\",\n          AVATAR_TONES[hash(e.id) % AVATAR_TONES.length],\n        )}\n      >\n        {e.name\n          .split(\" \")\n          .map((p) => p[0])\n          .slice(0, 2)\n          .join(\"\")}\n      </span>\n      <span className=\"flex min-w-0 flex-1 flex-col\">\n        <span className=\"flex items-center gap-1.5 truncate font-medium text-sm\">\n          <span className=\"truncate\">{e.name}</span>\n          {isYou ? (\n            <span className=\"rounded-sm bg-info/12 px-1 font-medium text-[10px] text-info-foreground uppercase tracking-wide\">\n              You\n            </span>\n          ) : null}\n        </span>\n        {pinned && ahead ? (\n          <span className=\"truncate text-muted-foreground text-xs tabular-nums\">\n            {compact.format(ahead.score - e.score)} behind {ahead.name.split(\" \")[0]}\n          </span>\n        ) : e.detail ? (\n          <span className=\"truncate text-muted-foreground text-xs\">{e.detail}</span>\n        ) : null}\n      </span>\n      <span className=\"flex shrink-0 flex-col items-end\">\n        <RollingNumber className=\"font-medium text-sm\" text={fmt.format(e.score)} />\n        <span className=\"relative h-4 w-full\">\n          <AnimatePresence>\n            {gain ? (\n              <motion.span\n                animate={{ opacity: 1, y: 0 }}\n                className=\"absolute right-0 whitespace-nowrap font-medium text-success-foreground text-xs tabular-nums leading-4\"\n                exit={{ opacity: 0, transition: { duration: 0.25 } }}\n                initial={{ opacity: 0, y: reduce ? 0 : 4 }}\n                key={gain.key}\n                transition={{ duration: 0.2, ease: [0.23, 1, 0.32, 1] }}\n              >\n                +{compact.format(gain.amount)}\n              </motion.span>\n            ) : null}\n          </AnimatePresence>\n        </span>\n      </span>\n    </motion.li>\n  );\n}\n\nexport function Leaderboard({\n  title = \"Leaderboard\",\n  caption,\n  entries,\n  youId,\n  visible = 5,\n  format = { maximumFractionDigits: 0 },\n  locale = \"en-US\",\n  action,\n  className,\n}: LeaderboardProps) {\n  const reduce = useReducedMotion();\n  const fmt = useMemo(() => new Intl.NumberFormat(locale, format), [locale, format]);\n  const compact = useMemo(\n    () => new Intl.NumberFormat(locale, { ...format, notation: \"compact\", maximumFractionDigits: 1 }),\n    [locale, format],\n  );\n  const ranked = useMemo(() => [...entries].sort((a, b) => b.score - a.score), [entries]);\n  const rankOf = (id: string) => ranked.findIndex((e) => e.id === id) + 1;\n\n  const prevScores = useRef<Map<string, number>>(new Map());\n  const [gains, setGains] = useState<Record<string, { amount: number; key: number }>>({});\n  useEffect(() => {\n    const next: Record<string, { amount: number; key: number }> = {};\n    for (const e of entries) {\n      const before = prevScores.current.get(e.id);\n      if (before !== undefined && e.score > before) next[e.id] = { amount: e.score - before, key: Date.now() };\n      prevScores.current.set(e.id, e.score);\n    }\n    if (Object.keys(next).length === 0) return;\n    setGains((g) => ({ ...g, ...next }));\n    const timer = setTimeout(() => {\n      setGains((g) => {\n        const copy = { ...g };\n        for (const id of Object.keys(next)) if (copy[id]?.key === next[id].key) delete copy[id];\n        return copy;\n      });\n    }, 1400);\n    return () => clearTimeout(timer);\n  }, [entries]);\n\n  const top = ranked.slice(0, visible);\n  const you = youId ? ranked.find((e) => e.id === youId) : undefined;\n  const youRank = you ? rankOf(you.id) : 0;\n  const youPinned = you && youRank > visible;\n  const ahead = you && youRank > 1 ? ranked[youRank - 2] : undefined;\n  const behind = you ? ranked[youRank] : undefined;\n\n  return (\n    <section\n      aria-label={title}\n      className={cn(\"@container w-full max-w-[420px] rounded-2xl border bg-card p-3 text-card-foreground shadow-xs/5\", className)}\n    >\n      <header className=\"flex items-center justify-between gap-3 px-2.5 pt-1.5 pb-3\">\n        <div className=\"flex min-w-0 flex-col\">\n          <h3 className=\"truncate font-medium text-sm\">{title}</h3>\n          {caption ? <span className=\"truncate text-muted-foreground text-xs\">{caption}</span> : null}\n        </div>\n        {action}\n      </header>\n      <LayoutGroup>\n        <ol aria-label={`${title}, top ${visible}`} className=\"flex flex-col gap-0.5\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n          {top.map((e) => (\n            <Row\n              compact={compact}\n              e={e}\n              fmt={fmt}\n              gain={gains[e.id]}\n              isYou={e.id === youId}\n              key={e.id}\n              rank={rankOf(e.id)}\n            />\n          ))}\n          </AnimatePresence>\n        </ol>\n        <div className=\"mt-1.5 border-t border-dashed pt-1.5\">\n          {youPinned && you ? (\n            <ol aria-label=\"Your position\">\n              <Row\n                ahead={ahead}\n                compact={compact}\n                e={you}\n                fmt={fmt}\n                gain={gains[you.id]}\n                isYou\n                pinned\n                rank={youRank}\n              />\n            </ol>\n          ) : (\n            <motion.p\n              animate={{ opacity: 1 }}\n              className=\"flex h-12 items-center justify-center gap-1.5 text-muted-foreground text-xs tabular-nums\"\n              initial={{ opacity: 0 }}\n              key={youRank}\n              transition={{ duration: 0.2 }}\n            >\n              {you\n                ? youRank === 1\n                  ? behind\n                    ? `You're on top, ${compact.format(you.score - behind.score)} clear of ${behind.name.split(\" \")[0]}`\n                    : \"You're on top\"\n                  : `You're #${youRank} · ${compact.format((ahead?.score ?? 0) - you.score)} to pass ${ahead?.name.split(\" \")[0]}`\n                : `${entries.length} people ranked`}\n            </motion.p>\n          )}\n        </div>\n      </LayoutGroup>\n    </section>\n  );\n}\n"
    }
  ]
}