{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "leaderboard-1",
  "title": "Leaderboard 1: podium and rankings",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A leaderboard: a podium with the leader raised, ranked rows with bars against the leader's score, movement written in words, a week, month and all-time switch, a search, and your own place highlighted and pinned.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/avatar.json",
    "https://ui.ballmac.com/r/blur-fade.json",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/cover-art.json",
    "https://ui.ballmac.com/r/number-ticker.json",
    "https://ui.ballmac.com/r/segmented-control.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/leaderboard-1/leaderboard-1.tsx",
      "content": "// Ballmac UI: Leaderboard 1. https://ui.ballmac.com/blocks/leaderboard-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowDown, ArrowUp, Crown, Minus, Search, Trophy, UsersRound } from \"lucide-react\"\n\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ballmac/avatar\"\nimport { BlurFade } from \"@/components/ballmac/blur-fade\"\nimport { Button } from \"@/components/ballmac/button\"\nimport { CoverArt } from \"@/components/ballmac/cover-art\"\nimport { NumberTicker } from \"@/components/ballmac/number-ticker\"\nimport { SegmentedControl, SegmentedControlItem } from \"@/components/ballmac/segmented-control\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Leaderboard1Period = \"week\" | \"month\" | \"all\"\n\ntype Leaderboard1Entry = {\n  id: string\n  name: string\n  /** A line under the name: a team, a city, a title. */\n  subtitle?: string\n  /** A picture, or leave out for art drawn from the name. */\n  avatar?: string\n  /** The score in each period. */\n  scores: Record<Leaderboard1Period, number>\n  /** How many places they moved since the period before: positive is up, negative is down. */\n  change?: Partial<Record<Leaderboard1Period, number>>\n  /** The person using the page. Highlighted, and pinned at the bottom when they are further down the list. */\n  isYou?: boolean\n}\n\ntype Leaderboard1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    entries?: Leaderboard1Entry[]\n    title?: string\n    description?: string\n    /** What the score is called (\"pts\", \"commits\", \"sales\"). */\n    unit?: string\n    defaultPeriod?: Leaderboard1Period\n    /** How many places show under the podium before Show more. */\n    pageSize?: number\n    /** BCP 47 locale for numbers. */\n    locale?: string\n    onPeriodChange?: (period: Leaderboard1Period) => void\n    /** Called when a person is chosen. Makes each row a button. */\n    onSelect?: (entry: Leaderboard1Entry) => void\n  }\n\nconst sample: Leaderboard1Entry[] = [\n  { id: \"p1\", name: \"Priya Shah\", subtitle: \"Platform\", scores: { week: 1840, month: 6120, all: 41200 }, change: { week: 1, month: 0, all: 0 } },\n  { id: \"p2\", name: \"Theo Nakamura\", subtitle: \"Tooling\", scores: { week: 1710, month: 6480, all: 39800 }, change: { week: -1, month: 1, all: 0 } },\n  { id: \"p3\", name: \"Mara Ellison\", subtitle: \"Design\", scores: { week: 1620, month: 5300, all: 36900 }, change: { week: 2, month: -1, all: 1 } },\n  { id: \"p4\", name: \"Alex Kim\", subtitle: \"Support\", scores: { week: 1480, month: 5050, all: 33100 }, change: { week: 0, month: 2, all: -1 } },\n  { id: \"p5\", name: \"Sam Okafor\", subtitle: \"Support\", scores: { week: 1390, month: 4720, all: 30400 }, change: { week: -2, month: 0, all: 0 } },\n  { id: \"p6\", name: \"Lena Fischer\", subtitle: \"Growth\", scores: { week: 1275, month: 4410, all: 28950 }, change: { week: 3, month: 1, all: 2 } },\n  { id: \"p7\", name: \"Diego Marín\", subtitle: \"Platform\", scores: { week: 1190, month: 4380, all: 27300 }, change: { week: -1, month: -1, all: -1 } },\n  { id: \"p8\", name: \"Noor Haddad\", subtitle: \"Design\", scores: { week: 1105, month: 3900, all: 25100 }, change: { week: 1, month: 0, all: 1 } },\n  { id: \"p9\", name: \"Jordan Lee\", subtitle: \"Product\", scores: { week: 1020, month: 3640, all: 22400 }, change: { week: 4, month: 2, all: 0 }, isYou: true },\n  { id: \"p10\", name: \"Kofi Mensah\", subtitle: \"Tooling\", scores: { week: 940, month: 3510, all: 21800 }, change: { week: -1, month: 0, all: -2 } },\n  { id: \"p11\", name: \"Yuki Tanaka\", subtitle: \"Growth\", scores: { week: 880, month: 3200, all: 19600 }, change: { week: 0, month: -2, all: 0 } },\n  { id: \"p12\", name: \"Ines Costa\", subtitle: \"Support\", scores: { week: 790, month: 2980, all: 18200 }, change: { week: -3, month: 1, all: 1 } },\n  { id: \"p13\", name: \"Oliver Reed\", subtitle: \"Product\", scores: { week: 720, month: 2760, all: 16900 }, change: { week: 0, month: 0, all: 0 } },\n  { id: \"p14\", name: \"Amara Okoye\", subtitle: \"Design\", scores: { week: 640, month: 2410, all: 15200 }, change: { week: 1, month: -1, all: -1 } },\n]\n\nconst periodLabel: Record<Leaderboard1Period, string> = { week: \"This week\", month: \"This month\", all: \"All time\" }\nconst initials = (name: string) => name.split(\" \").map((w) => w[0]).join(\"\").slice(0, 2)\n\nconst podium = [\n  { rank: 1, height: \"h-36 sm:h-48\", tone: \"from-chart-3/45 to-chart-3/5\", ring: \"ring-chart-3\", numeral: \"text-chart-3\" },\n  { rank: 2, height: \"h-28 sm:h-36\", tone: \"from-foreground/20 to-foreground/[0.03]\", ring: \"ring-muted-foreground/60\", numeral: \"text-muted-foreground\" },\n  { rank: 3, height: \"h-20 sm:h-28\", tone: \"from-chart-4/35 to-chart-4/5\", ring: \"ring-chart-4\", numeral: \"text-chart-4\" },\n] as const\n\nfunction Face({ entry, className }: { entry: Leaderboard1Entry; className?: string }) {\n  return (\n    <Avatar className={cn(\"shrink-0\", className)}>\n      {entry.avatar ? <AvatarImage src={entry.avatar} alt=\"\" /> : null}\n      <AvatarFallback className=\"text-foreground relative overflow-hidden bg-transparent font-semibold\">\n        <CoverArt seed={entry.name} variant=\"mesh\" className=\"absolute inset-0\" />\n        <span className=\"relative\">{initials(entry.name)}</span>\n      </AvatarFallback>\n    </Avatar>\n  )\n}\n\nfunction Movement({ change }: { change?: number }) {\n  if (change === undefined) return <span className=\"w-12 sm:w-14\" />\n  const Icon = change > 0 ? ArrowUp : change < 0 ? ArrowDown : Minus\n  return (\n    <span className={cn(\"flex w-12 items-center justify-end gap-1 text-xs tabular-nums sm:w-14\", change > 0 ? \"text-foreground font-medium\" : \"text-muted-foreground\")}>\n      <Icon aria-hidden=\"true\" className={cn(\"size-3.5\", change > 0 && \"text-chart-2\", change < 0 && \"text-destructive\")} />\n      <span aria-hidden=\"true\">{change === 0 ? \"\" : Math.abs(change)}</span>\n      <span className=\"sr-only\">{change === 0 ? \"No change\" : `${change > 0 ? \"Up\" : \"Down\"} ${Math.abs(change)} ${Math.abs(change) === 1 ? \"place\" : \"places\"}`}</span>\n    </span>\n  )\n}\n\nfunction BoardSkeleton() {\n  return (\n    <div className=\"grid gap-8\" aria-hidden=\"true\">\n      <div className=\"grid grid-cols-3 items-end gap-3 sm:gap-6\">\n        <Skeleton className=\"h-56 rounded-t-3xl\" />\n        <Skeleton className=\"h-72 rounded-t-3xl\" />\n        <Skeleton className=\"h-48 rounded-t-3xl\" />\n      </div>\n      {[0, 1, 2, 3, 4].map((i) => (\n        <Skeleton key={i} className=\"h-14 rounded-xl\" />\n      ))}\n    </div>\n  )\n}\n\n/**\n * A leaderboard: a podium with the leader raised, the rest as ranked rows with a bar against the leader's score that grows in, rank\n * movement written in words, a switch between week, month and all time, a way to find someone, and your own place highlighted and\n * pinned when you are further down the list.\n */\nfunction Leaderboard1({\n  entries: entriesProp = sample,\n  title = \"Leaderboard\",\n  description = \"Who is moving the most. Scores update every night.\",\n  unit = \"pts\",\n  defaultPeriod = \"week\",\n  pageSize = 8,\n  locale = \"en-US\",\n  onPeriodChange,\n  onSelect,\n  state = \"ready\",\n  onRetry,\n  emptyState,\n  errorState,\n  className,\n  ...props\n}: Leaderboard1Props) {\n  const id = React.useId()\n  const [period, setPeriod] = React.useState<Leaderboard1Period>(defaultPeriod)\n  const [query, setQuery] = React.useState(\"\")\n  const [shown, setShown] = React.useState(pageSize)\n  const [grown, setGrown] = React.useState(false)\n  const ready = state === \"ready\" || state === \"empty\"\n  const nf = new Intl.NumberFormat(locale)\n\n  // bars start empty and grow once the page is on screen\n  React.useEffect(() => {\n    const t = requestAnimationFrame(() => setGrown(true))\n    return () => cancelAnimationFrame(t)\n  }, [])\n  React.useEffect(() => setShown(pageSize), [period, query, pageSize])\n\n  const ranked = React.useMemo(\n    () =>\n      state === \"empty\"\n        ? []\n        : [...entriesProp]\n            .sort((a, b) => b.scores[period] - a.scores[period] || a.name.localeCompare(b.name))\n            .map((e, i) => ({ ...e, rank: i + 1, score: e.scores[period], delta: e.change?.[period] })),\n    [entriesProp, period, state]\n  )\n  const top = ranked.slice(0, 3)\n  const leader = ranked[0]?.score ?? 1\n  const q = query.trim().toLowerCase()\n  const rest = ranked.slice(3).filter((e) => !q || `${e.name} ${e.subtitle ?? \"\"}`.toLowerCase().includes(q))\n  const visible = rest.slice(0, shown)\n  const you = ranked.find((e) => e.isYou)\n  const youHidden = !!you && you.rank > 3 && !visible.some((v) => v.id === you.id) && !q\n\n  const row = (e: (typeof ranked)[number], pinned = false) => {\n    const body = (\n      <>\n        <span className=\"text-muted-foreground w-8 shrink-0 text-center font-mono text-sm tabular-nums\">{e.rank}</span>\n        <Face entry={e} className=\"size-10\" />\n        <span className=\"min-w-0 flex-1\">\n          <span className=\"flex items-center gap-2 text-sm font-medium\">\n            <span className=\"truncate\">{e.name}</span>\n            {e.isYou ? <span className=\"bg-primary text-primary-foreground shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold\">You</span> : null}\n          </span>\n          {e.subtitle ? <span className=\"text-muted-foreground block truncate text-xs\">{e.subtitle}</span> : null}\n        </span>\n        <span aria-hidden=\"true\" className=\"bg-muted relative hidden h-1.5 w-32 shrink-0 overflow-hidden rounded-full md:block lg:w-44\">\n          <span className=\"bg-foreground/70 absolute inset-y-0 start-0 rounded-full transition-[width] duration-700 ease-out motion-reduce:transition-none\" style={{ width: grown ? `${Math.max(4, (e.score / leader) * 100)}%` : \"0%\" }} />\n        </span>\n        <span className=\"shrink-0 text-end text-sm font-semibold tabular-nums sm:w-20\">\n          {nf.format(e.score)}\n          <span className=\"text-muted-foreground ms-1 hidden text-xs font-normal sm:inline\">{unit}</span>\n        </span>\n        <Movement change={e.delta} />\n      </>\n    )\n    const cls = cn(\"flex w-full items-center gap-3 rounded-xl border px-3 py-2.5 text-start transition-[background-color,border-color] duration-150 sm:gap-4 sm:px-4\", e.isYou ? \"bg-accent border-foreground/25\" : \"bg-card hover:border-foreground/20\", pinned && \"shadow-lg\")\n    return onSelect ? (\n      <button type=\"button\" onClick={() => onSelect(e)} aria-label={`Rank ${e.rank}: ${e.name}`} className={cn(cls, \"focus-visible:ring-ring/50 outline-none focus-visible:ring-[3px]\")}>\n        {body}\n      </button>\n    ) : (\n      <div className={cls}>{body}</div>\n    )\n  }\n\n  const empty = ready && ranked.length === 0\n\n  const content = empty ? (\n    <div className=\"bg-card rounded-2xl border px-6 py-16 text-center\">\n      <span aria-hidden=\"true\" className=\"bg-muted mx-auto flex size-12 items-center justify-center rounded-full\">\n        <UsersRound className=\"size-6\" />\n      </span>\n      <h2 className=\"mt-5 text-lg font-semibold tracking-tight\">{emptyState?.title ?? \"No scores yet\"}</h2>\n      <p className=\"text-muted-foreground mx-auto mt-1.5 max-w-xs text-sm text-pretty\">{emptyState?.description ?? \"The board fills up as soon as the first points are earned. Be the first.\"}</p>\n      {emptyState?.action}\n    </div>\n  ) : (\n    <div className=\"grid grid-cols-1 gap-10\">\n      <ol aria-label=\"Top three\" className=\"border-border mx-auto grid w-full max-w-2xl list-none grid-cols-3 items-end gap-2 border-b sm:gap-6\">\n        {[top[1], top[0], top[2]].map((e, slot) => {\n          if (!e) return <li key={slot} />\n          const P = podium[e.rank - 1]!\n          const first = e.rank === 1\n          return (\n            <BlurFade key={e.id} as=\"li\" direction=\"up\" offset={first ? 30 : 22} duration={0.7} delay={first ? 0 : slot === 0 ? 0.12 : 0.22} className=\"flex flex-col items-center\">\n              <span className=\"sr-only\">\n                Rank {e.rank}: {e.name}, {nf.format(e.score)} {unit}\n              </span>\n              <div aria-hidden=\"true\" className=\"flex w-full flex-col items-center\">\n                <div className=\"relative\">\n                  {first ? <Crown className=\"text-chart-3 fill-chart-3/30 absolute -top-7 left-1/2 size-7 -translate-x-1/2\" /> : null}\n                  <Face entry={e} className={cn(\"ring-4 ring-offset-2 ring-offset-background\", P.ring, first ? \"size-20 sm:size-28\" : \"size-14 sm:size-20\")} />\n                </div>\n                <p className=\"mt-3 flex max-w-full items-center gap-1.5 text-sm font-semibold sm:text-base\">\n                  <span className=\"truncate\">{e.name}</span>\n                  {e.isYou ? <span className=\"bg-primary text-primary-foreground rounded-full px-1.5 text-[10px]\">You</span> : null}\n                </p>\n                {e.subtitle ? <p className=\"text-muted-foreground hidden text-xs sm:block\">{e.subtitle}</p> : null}\n                <p className=\"mt-1 text-xl font-semibold tracking-tight tabular-nums sm:text-3xl\">\n                  <NumberTicker value={e.score} locale={locale} />\n                </p>\n                <p className=\"text-muted-foreground text-[11px] sm:text-xs\">{unit}</p>\n                <div className={cn(\"relative mt-4 w-full overflow-hidden rounded-t-2xl border border-b-0 bg-gradient-to-b\", P.tone, P.height)}>\n                  <span className={cn(\"absolute inset-x-0 top-2 text-center font-mono text-4xl font-semibold opacity-50 sm:top-3 sm:text-6xl\", P.numeral)}>{e.rank}</span>\n                </div>\n              </div>\n            </BlurFade>\n          )\n        })}\n      </ol>\n\n      {rest.length || q ? (\n        <div className=\"mx-auto w-full max-w-3xl\">\n          <div className=\"bg-background focus-within:ring-ring/50 mb-4 flex h-9 items-center gap-2 rounded-md border px-2.5 focus-within:ring-[3px]\">\n            <Search aria-hidden=\"true\" className=\"text-muted-foreground size-4\" />\n            <input type=\"search\" aria-label=\"Find a person\" placeholder=\"Find a person\" value={query} onChange={(e) => setQuery(e.target.value)} className=\"placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent text-sm outline-none\" />\n          </div>\n          <ol start={4} aria-label=\"Rankings from fourth place\" className=\"grid list-none grid-cols-1 gap-2\">\n            {visible.map((e) => (\n              <li key={e.id} className=\"min-w-0\">{row(e)}</li>\n            ))}\n            {!visible.length ? <li className=\"text-muted-foreground py-8 text-center text-sm\">Nobody matches “{query}”.</li> : null}\n          </ol>\n          {rest.length > visible.length ? (\n            <div className=\"mt-5 flex flex-col items-center gap-2\">\n              <p className=\"text-muted-foreground text-xs tabular-nums\">\n                Showing {visible.length + 3} of {ranked.length}\n              </p>\n              <Button variant=\"outline\" onClick={() => setShown((n) => n + pageSize)}>\n                Show {Math.min(pageSize, rest.length - visible.length)} more\n              </Button>\n            </div>\n          ) : null}\n          {youHidden && you ? (\n            <div className=\"sticky bottom-4 z-10 mt-6\">\n              <p className=\"text-muted-foreground mb-1.5 text-center text-xs\">Your place</p>\n              {row(you, true)}\n            </div>\n          ) : null}\n        </div>\n      ) : null}\n    </div>\n  )\n\n  return (\n    <section data-slot=\"leaderboard-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-5xl px-4 py-12 sm:px-6\", className)} {...frameProps(props)}>\n      <header className=\"mb-12 flex flex-wrap items-end justify-between gap-6\">\n        <div className=\"max-w-xl\">\n          <h1 id={`${id}-title`} className=\"flex items-center gap-3 text-4xl font-semibold tracking-[-0.04em] sm:text-5xl\">\n            <Trophy aria-hidden=\"true\" className=\"text-chart-3 size-8 sm:size-10\" />\n            {title}\n          </h1>\n          <p className=\"text-muted-foreground mt-3 text-base text-pretty\">{description}</p>\n        </div>\n        <SegmentedControl\n          value={period}\n          onValueChange={(v) => {\n            setPeriod(v as Leaderboard1Period)\n            onPeriodChange?.(v as Leaderboard1Period)\n          }}\n          aria-label=\"Period\"\n          disabled={!ready}\n        >\n          {(Object.keys(periodLabel) as Leaderboard1Period[]).map((p) => (\n            <SegmentedControlItem key={p} value={p}>\n              {periodLabel[p]}\n            </SegmentedControlItem>\n          ))}\n        </SegmentedControl>\n      </header>\n      <StateView state={state === \"loading\" || state === \"error\" ? state : \"ready\"} onRetry={onRetry} label=\"Loading the leaderboard\" loading={<BoardSkeleton />} error={{ title: errorState?.title ?? \"The leaderboard could not be loaded\", description: errorState?.description ?? \"Check your connection and try again.\", ...errorState }}>\n        {content}\n      </StateView>\n    </section>\n  )\n}\n\nexport { Leaderboard1, type Leaderboard1Props, type Leaderboard1Entry, type Leaderboard1Period }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/leaderboard-1/leaderboard-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "leaderboard",
      "ranking",
      "podium",
      "scores",
      "gamification",
      "community",
      "top users",
      "contest",
      "competition"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "A ranked board. <Leaderboard1 entries={[{ id, name, subtitle, avatar, scores: { week, month, all }, change: { week, month, all }, isYou }]} unit=\"pts\" defaultPeriod pageSize locale onPeriodChange onSelect />. The block sorts by the chosen period and numbers the ranks itself; change is how many places a person moved (positive is up). Mark the signed-in user with isYou.",
      "whenToUse": [
        "Community, sales, support or learning rankings",
        "A contest or challenge board"
      ],
      "whenNotToUse": [
        "A table of data with many columns (use data-table)",
        "Showing one person's stats (use stat-card or profile-1)"
      ],
      "composesWith": [
        "profile-1",
        "app-shell-1",
        "stat-card",
        "header-2"
      ],
      "a11y": [
        {
          "keys": "Podium",
          "action": "Read as an ordered list: \"Rank 1: name, 1,840 pts\"; the decorative numerals and crown are hidden"
        },
        {
          "keys": "Movement",
          "action": "Up 2 places, Down 1 place or No change are words for screen readers next to the arrow"
        },
        {
          "keys": "Rows",
          "action": "With onSelect each row is one button named by rank and person; the period switch is a radio group"
        }
      ],
      "customization": [
        "unit, title, description, pageSize, locale",
        "scores for all three periods can come from your API; the block does the sorting and ranking",
        "The podium tones use chart colors, so a theme change recolors them"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "leaderboard-1-loading",
        "trigger": "state=\"loading\"",
        "note": "A skeleton podium and rows under the real heading, announced as loading; the period switch is disabled."
      },
      {
        "id": "empty",
        "title": "No scores yet",
        "example": "leaderboard-1-empty",
        "trigger": "state=\"empty\" (or entries={[]})",
        "note": "A friendly message that the board fills up with the first points; emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "leaderboard-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert with Try again (onRetry)."
      }
    ],
    "examples": [
      "leaderboard-1-demo",
      "leaderboard-1-pinned",
      "leaderboard-1-loading",
      "leaderboard-1-empty",
      "leaderboard-1-error"
    ],
    "url": "https://ui.ballmac.com/components/leaderboard-1"
  },
  "categories": [
    "blocks",
    "leaderboard"
  ],
  "type": "registry:block"
}