Leaderboard
A ranked list that re-sorts with FLIP motion when the period changes; rank changes show as small up / down marks.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/leaderboard.json'use client'
import { useLayoutEffect, useRef, useState } from 'react'
import './leaderboard.css'
export interface LbRow { id: string; name: string; score: number }
export interface LeaderboardProps {
data: Record<string, LbRow[]>
meId?: string
top?: number
fill?: 'dark' | 'light'
className?: string
}
/** Ranked list with FLIP re-ordering between periods. */
export function Leaderboard({ data, meId, top = 5, fill = 'dark', className }: LeaderboardProps) {
const periods = Object.keys(data)
const [p, setP] = useState(periods[0])
const prevRank = useRef<Record<string, number>>({})
const [delta, setDelta] = useState<Record<string, number>>({})
const refs = useRef<Record<string, HTMLLIElement | null>>({})
const rects = useRef<Record<string, DOMRect>>({})
const ranked = [...data[p]].sort((a, b) => b.score - a.score)
const shown = ranked.slice(0, top)
const meIdx = ranked.findIndex((r) => r.id === meId)
const pinned = meIdx >= top ? ranked[meIdx] : null
const switchTo = (n: string) => {
for (const [id, el] of Object.entries(refs.current)) if (el) rects.current[id] = el.getBoundingClientRect()
const old: Record<string, number> = {}; ranked.forEach((r, i) => (old[r.id] = i))
prevRank.current = old; setP(n)
}
useLayoutEffect(() => {
const d: Record<string, number> = {}
ranked.forEach((r, i) => { if (prevRank.current[r.id] !== undefined) d[r.id] = prevRank.current[r.id] - i })
setDelta(d)
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return
for (const [id, el] of Object.entries(refs.current)) {
const a = rects.current[id]; if (!el || !a) continue
const b = el.getBoundingClientRect()
el.animate([{ transform: `translateY(${a.top - b.top}px)` }, { transform: 'none' }], { duration: 480, easing: 'cubic-bezier(0.3, 1.1, 0.4, 1)' })
}
rects.current = {}
}, [p]) // eslint-disable-line react-hooks/exhaustive-deps
const row = (r: LbRow, i: number) => {
const d = delta[r.id] ?? 0
return (
<li key={r.id} ref={(el) => { refs.current[r.id] = el }} className="sd-ldb__r" data-me={r.id === meId || undefined} aria-label={`${i + 1}. ${r.name}, ${r.score} points${d ? `, ${d > 0 ? 'up' : 'down'} ${Math.abs(d)}` : ''}`}>
<span className="sd-ldb__rank" aria-hidden>{i + 1}</span>
<span className="sd-ldb__av" aria-hidden>{r.name.slice(0, 2)}</span>
<span className="sd-ldb__n" aria-hidden>{r.name}{r.id === meId ? ' (you)' : ''}</span>
<span className="sd-ldb__s" aria-hidden>{r.score.toLocaleString()}</span>
<span className="sd-ldb__d" aria-hidden>{d > 0 ? `▲${d}` : d < 0 ? `▼${-d}` : '–'}</span>
</li>
)
}
return (
<div className={['sd-ldb', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-ldb__f" role="radiogroup" aria-label="Period">{periods.map((x) => <button key={x} type="button" role="radio" aria-checked={p === x} onClick={() => switchTo(x)}>{x}</button>)}</div>
<ol>{shown.map(row)}</ol>
{pinned && <><div className="sd-ldb__gap" aria-hidden>···</div><ol start={meIdx + 1}>{row(pinned, meIdx)}</ol></>}
</div>
)
}.sd-ldb { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-ldb[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-ldb button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-ldb :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-ldb .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-ldb { width: min(100%, 320px); padding: 12px; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); font-variant-numeric: tabular-nums; }
.sd-ldb__f { display: grid; grid-template-columns: repeat(3, 1fr); gap: 3px; padding: 3px; margin-bottom: 8px; border-radius: 11px; background: var(--soft); }
.sd-ldb__f button { height: 30px; border-radius: 8px; font-size: 12px; color: var(--mute); }
.sd-ldb__f button[aria-checked='true'] { background: var(--bg) !important; color: var(--fg); font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); }
.sd-ldb ol { list-style: none; margin: 0; padding: 0; }
.sd-ldb__r { display: grid; grid-template-columns: 26px 30px 1fr auto 34px; gap: 8px; align-items: center; height: 46px; padding: 0 8px; border-radius: 11px; }
.sd-ldb__r[data-me] { background: var(--soft); box-shadow: inset 0 0 0 1px var(--line); }
.sd-ldb__rank { font-weight: 700; font-size: 13px; color: var(--mute); text-align: center; }
.sd-ldb__r:nth-child(1) .sd-ldb__rank { color: var(--fg); }
.sd-ldb__av { width: 30px; height: 30px; border-radius: 99px; background: var(--soft); display: grid; place-items: center; font-size: 11px; font-weight: 650; }
.sd-ldb__r:nth-child(1) .sd-ldb__av { background: var(--strong); color: var(--on); }
.sd-ldb__n { font-size: 13.5px; font-weight: 550; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sd-ldb__s { font-size: 13px; font-weight: 650; }
.sd-ldb__d { font-size: 10.5px; color: var(--mute); text-align: right; }
.sd-ldb__gap { text-align: center; color: var(--mute); font-size: 12px; letter-spacing: 2px; padding: 2px 0; }
@media (prefers-reduced-motion: reduce) { .sd-ldb *, .sd-ldb *::before, .sd-ldb *::after { animation: none !important; transition: none !important; } }Overview
Switching between This week, This month and All time re-ranks the rows and each row glides to its new place, so you can follow who moved. A ▲3 / ▼1 mark shows the change.
Your own row stays highlighted and pinned at the bottom if it falls out of the top list.
When to use
- Gamified apps, sales dashboards, community rankings.
When not to use
- Contexts where ranking people would demotivate them.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/leaderboard.json$
npx shadcn@latest add https://sundaeui.dev/r/leaderboard.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/leaderboard.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/leaderboard.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy leaderboard.tsx and leaderboard.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { Leaderboard } from '@/components/sundae/leaderboard'
<Leaderboard />Props
| Prop | Type | Default |
|---|---|---|
data*Rows per period. | Record<string, { id: string; name: string; score: number }[]> | — |
meIdYour row id. | string | — |
topRows shown. | number | 5 |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Period is a radiogroup; the list is an ordered list whose items say rank, name, score and change.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-ldb; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Leaderboard free?
- Yes. Leaderboard is free for personal and commercial projects, with full source on this page.
- How do I install Leaderboard?
- Run npx shadcn@latest add https://sundaeui.dev/r/leaderboard.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Leaderboard support keyboard and screen readers?
- Period is a radiogroup; the list is an ordered list whose items say rank, name, score and change. Reduced motion removes the movement and keeps every state change.
- Does Leaderboard respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All data →Transaction List
Data · RefinedWatchlist
Data · RefinedStacked Bars
Data · Refined- Pro
Scrub Sparkline
Data · Refined - Pro
Tick Gauge
Data · Refined Countdown Timer
Data · Refined