A dark leaderboard table for a running club challenge that updates in front of you. Each time a run lands, that row flashes, its distance rolls like an odometer, and if the total passes someone the two rows slide past each other on a spring. Rank numbers flip, gold, silver and bronze medals change hands, the crown hops to a new leader, and a green or red badge shows how many places a row moved. A thin bar per row shows the gap to first place. One row is marked as you, with a button to log a run and climb. On phones the move and gap columns are dropped.
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "framer-motion";
type Runner = { id: string; name: string; city: string; initials: string; hue: string; km: number; you?: boolean };
const runners: Runner[] = [
{ id: "saoirse", name: "Saoirse Daly", city: "Cork", initials: "SD", hue: "bg-sky-400", km: 212.4 },
{ id: "mateus", name: "Mateus Rocha", city: "Porto", initials: "MR", hue: "bg-emerald-400", km: 208.9 },
{ id: "ingrid", name: "Ingrid Solberg", city: "Bergen", initials: "IS", hue: "bg-fuchsia-400", km: 204.1 },
{ id: "kwame", name: "Kwame Asante", city: "Accra", initials: "KA", hue: "bg-orange-400", km: 197.6 },
{ id: "yuki", name: "Yuki Tanabe", city: "Osaka", initials: "YT", hue: "bg-rose-400", km: 193.0 },
{ id: "you", name: "Dana Whitlock", city: "Leeds", initials: "DW", hue: "bg-amber-400", km: 188.2, you: true },
{ id: "luca", name: "Luca Ferri", city: "Bologna", initials: "LF", hue: "bg-teal-400", km: 181.7 },
{ id: "priya", name: "Priya Nair", city: "Kochi", initials: "PN", hue: "bg-violet-400", km: 176.3 },
];
/** Runs that arrive while the board is live: [runner id, kilometres]. After the list it carries on with made-up runs. */
const INCOMING: [string, number][] = [
["you", 6.4],
["mateus", 5.2],
["priya", 7.8],
["you", 4.9],
["ingrid", 11.3],
["luca", 8.6],
["saoirse", 6.1],
["you", 7.2],
];
const TICK_MS = 1300;
const SPRING = { type: "spring" as const, stiffness: 330, damping: 30, mass: 0.9 };
const medal = ["bg-amber-400 text-amber-950", "bg-zinc-300 text-zinc-900", "bg-orange-700 text-orange-50"];
function Digit({ value, still }: { value: number; still: boolean }) {
return (
<span className="relative inline-block h-[1em] w-[0.62em] overflow-hidden">
<motion.span className="absolute left-0 top-0 flex w-full flex-col" initial={false} animate={{ y: `${-value * 10}%` }} transition={still ? { duration: 0 } : { type: "spring", stiffness: 190, damping: 22 }}>
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (
<span key={n} className="flex h-[1em] items-center justify-center">
{n}
</span>
))}
</motion.span>
</span>
);
}
function Rolling({ value, still }: { value: number; still: boolean }) {
const text = value.toFixed(1);
return (
<span className="inline-flex items-center leading-none">
<span className="sr-only">{text} kilometres</span>
<span aria-hidden="true" className="inline-flex items-center">
{text.split("").map((ch, i) => (ch === "." ? <span key={i}>.</span> : <Digit key={text.length - i} value={Number(ch)} still={still} />))}
</span>
</span>
);
}
export default function LiveLeaderboardWithAnimatedRankChangesAndRollingScores() {
const reduce = !!useReducedMotion();
const [km, setKm] = useState<Record<string, number>>(() => Object.fromEntries(runners.map((r) => [r.id, r.km])));
const [moved, setMoved] = useState<Record<string, number>>({});
const [hot, setHot] = useState<string | null>(null);
const [tick, setTick] = useState(0);
const [live, setLive] = useState(true);
const board = useRef<HTMLDivElement>(null);
const visible = useInView(board, { amount: 0.25 });
const rankOf = (totals: Record<string, number>) => [...runners].sort((a, b) => totals[b.id] - totals[a.id]).map((r) => r.id);
const log = (id: string, distance: number) => {
setKm((before) => {
const after = { ...before, [id]: Math.round((before[id] + distance) * 10) / 10 };
const was = rankOf(before);
const now = rankOf(after);
setMoved(Object.fromEntries(now.map((r, i) => [r, was.indexOf(r) - i]).filter(([, d]) => d !== 0)));
return after;
});
setHot(id);
setTick((t) => t + 1);
};
// Live feed: one run lands every tick while the board is on screen.
useEffect(() => {
if (!live || reduce || !visible) return;
const t = window.setTimeout(
() => {
const [id, distance] = INCOMING[tick] ?? [runners[(tick * 5 + 3) % runners.length].id, 3 + ((tick * 37) % 60) / 10];
log(id, distance);
},
tick === 0 ? 900 : TICK_MS,
);
return () => window.clearTimeout(t);
}, [live, reduce, visible, tick]);
const order = rankOf(km);
const top = km[order[0]];
const base = 150;
return (
<section className="bg-[#0d0b14] px-4 py-14 sm:px-6 sm:py-16">
<style>{`
@keyframes live-leaderboard-sync { from { transform: scaleX(0) } to { transform: scaleX(1) } }
@keyframes live-leaderboard-flash { from { opacity: 0.55 } to { opacity: 0 } }
@media (prefers-reduced-motion: reduce) { .live-leaderboard-anim { animation: none !important; opacity: 0; } }
`}</style>
<div className="mx-auto max-w-3xl">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-sm font-semibold text-amber-400">Harbour Run Club · October challenge</p>
<h2 className="mt-1 text-3xl font-semibold tracking-tight text-white sm:text-4xl">Most kilometres this month</h2>
<p className="mt-2 max-w-md text-base text-zinc-400">Every logged run lands here as it happens. Rows trade places and the totals roll.</p>
</div>
<div className="flex gap-2">
<button
type="button"
aria-pressed={live}
onClick={() => setLive((l) => !l)}
className="flex h-11 items-center gap-2 rounded-full border border-white/15 bg-white/5 px-4 text-sm font-semibold text-white hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400"
>
<span className="relative flex h-2 w-2">
{live && <span className="absolute inset-0 animate-ping rounded-full bg-emerald-400 motion-reduce:animate-none" />}
<span className={`relative h-2 w-2 rounded-full ${live ? "bg-emerald-400" : "bg-zinc-500"}`} />
</span>
{live ? "Live" : "Paused"}
</button>
<button
type="button"
onClick={() => log("you", 5)}
className="h-11 rounded-full bg-amber-400 px-4 text-sm font-semibold text-amber-950 transition-transform duration-150 hover:bg-amber-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:ring-offset-2 focus-visible:ring-offset-[#0d0b14] active:scale-95 motion-reduce:transition-none"
>
Log my 5 km
</button>
</div>
</div>
<div ref={board} role="table" aria-label="Leaderboard, most kilometres first" className="mt-8 overflow-hidden rounded-3xl border border-white/10 bg-[#15121f] p-2 sm:p-3">
<div role="rowgroup">
<div role="row" className="grid grid-cols-[44px_1fr_88px] items-center gap-2 px-3 pb-2 pt-1 text-xs font-medium uppercase tracking-wide text-zinc-400 sm:grid-cols-[48px_1fr_64px_150px_96px] sm:gap-3">
<span role="columnheader">Rank</span>
<span role="columnheader">Runner</span>
<span role="columnheader" className="hidden text-center sm:block">
Move
</span>
<span role="columnheader" className="hidden sm:block">
To the leader
</span>
<span role="columnheader" className="text-right">
Distance
</span>
</div>
</div>
<div role="rowgroup" className="space-y-1.5">
{order.map((id, i) => {
const r = runners.find((x) => x.id === id)!;
const shift = moved[id] ?? 0;
return (
<motion.div
key={id}
role="row"
layout={reduce ? false : "position"}
initial={reduce ? false : { opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0, zIndex: hot === id ? 2 : shift ? 1 : 0, scale: 1 }}
transition={{ layout: SPRING, opacity: { duration: 0.4, delay: 0.04 * i }, y: { ...SPRING, delay: 0.04 * i } }}
className={`relative grid h-14 grid-cols-[44px_1fr_88px] items-center gap-2 rounded-2xl px-3 sm:grid-cols-[48px_1fr_64px_150px_96px] sm:gap-3 ${
r.you ? "bg-amber-400/10 shadow-[inset_0_0_0_1px_rgba(251,191,36,0.55)]" : i === 0 ? "bg-white/[0.07]" : "bg-white/[0.03]"
}`}
>
{hot === id && tick > 0 && <span key={tick} aria-hidden="true" className="live-leaderboard-anim pointer-events-none absolute inset-0 rounded-2xl bg-amber-300/40 opacity-0 animate-[live-leaderboard-flash_0.9s_ease-out]" />}
<span role="cell" className="relative">
<span className={`flex h-8 w-8 items-center justify-center overflow-hidden rounded-full text-sm font-bold tabular-nums transition-colors duration-300 motion-reduce:transition-none ${medal[i] ?? "bg-white/5 text-zinc-300"}`}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={i}
initial={reduce ? false : { y: shift > 0 ? 16 : -16, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={reduce ? { opacity: 0 } : { y: shift > 0 ? -16 : 16, opacity: 0 }}
transition={{ type: "spring", stiffness: 420, damping: 28 }}
>
{i + 1}
</motion.span>
</AnimatePresence>
</span>
</span>
<span role="cell" className="relative flex min-w-0 items-center gap-3">
<span className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-bold text-zinc-950 ${r.hue}`}>{r.initials}</span>
<span className="min-w-0">
<span className="flex items-center gap-1.5 text-sm font-semibold text-white">
<span className="truncate">{r.name}</span>
{r.you && <span className="rounded-full bg-amber-400 px-1.5 py-0.5 text-[10px] font-bold uppercase leading-none text-amber-950">You</span>}
<AnimatePresence>
{i === 0 && (
<motion.svg
key="crown"
viewBox="0 0 20 20"
className="h-4 w-4 shrink-0 text-amber-400"
fill="currentColor"
aria-label="Leader"
role="img"
initial={reduce ? false : { scale: 0, rotate: -40, y: -8 }}
animate={{ scale: 1, rotate: 0, y: 0 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ type: "spring", stiffness: 420, damping: 14 }}
>
<path d="M2.5 6l4 3.5L10 4l3.5 5.5 4-3.5-1.5 9h-12z" />
</motion.svg>
)}
</AnimatePresence>
</span>
<span className="block truncate text-xs text-zinc-400">{r.city}</span>
</span>
</span>
<span role="cell" className="relative hidden h-7 items-center justify-center sm:flex">
<AnimatePresence mode="wait" initial={false}>
{shift !== 0 ? (
<motion.span
key={`${tick}-${shift}`}
initial={reduce ? false : { scale: 0.4, opacity: 0, y: shift > 0 ? 10 : -10 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.6, opacity: 0 }}
transition={{ type: "spring", stiffness: 480, damping: 20 }}
className={`flex items-center gap-1 rounded-full px-2 py-1 text-xs font-bold tabular-nums ${shift > 0 ? "bg-emerald-400/15 text-emerald-300" : "bg-rose-400/15 text-rose-300"}`}
>
<svg viewBox="0 0 12 12" className={`h-2.5 w-2.5 ${shift > 0 ? "" : "rotate-180"}`} fill="currentColor" aria-hidden="true">
<path d="M6 1.5l5 8H1z" />
</svg>
<span className="sr-only">{shift > 0 ? "Up" : "Down"}</span>
{Math.abs(shift)}
</motion.span>
) : (
<motion.span key="same" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="h-0.5 w-3 rounded-full bg-zinc-600">
<span className="sr-only">No change</span>
</motion.span>
)}
</AnimatePresence>
</span>
<span role="cell" className="relative hidden sm:block">
<span className="block h-1.5 overflow-hidden rounded-full bg-white/10">
<motion.span
className={`block h-full w-full origin-left rounded-full ${r.you ? "bg-amber-400" : "bg-zinc-300"}`}
initial={reduce ? false : { scaleX: 0 }}
animate={{ scaleX: Math.max(0.04, (km[id] - base) / (top - base)) }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 120, damping: 20 }}
/>
</span>
</span>
<span role="cell" className={`relative flex items-baseline justify-end gap-1 text-lg font-semibold tabular-nums ${r.you ? "text-amber-300" : "text-white"}`}>
<Rolling value={km[id]} still={reduce} />
<span className="text-xs font-medium text-zinc-400">km</span>
</span>
</motion.div>
);
})}
</div>
<div className="mt-3 flex items-center gap-3 px-3 pb-1 text-xs text-zinc-400">
<span aria-live="polite">{live ? "Syncing with the club feed" : "Feed paused"} · 8 of 214 runners</span>
<span aria-hidden="true" className="h-0.5 flex-1 overflow-hidden rounded-full bg-white/10">
{live && <span key={tick} className="live-leaderboard-anim block h-full w-full origin-left bg-amber-400/70" style={{ animation: `live-leaderboard-sync ${tick === 0 ? 900 : TICK_MS}ms linear forwards` }} />}
</span>
</div>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/live-leaderboard-with-animated-rank-changes-and-rolling-scores.json
Rows are rendered from the sorted list with a stable `key` and the `layout="position"` prop. When the order changes, each row is measured before and after and animated between the two positions with a spring, using transforms only.
Each digit is a clipped box one character tall holding a column of 0 to 9. The column is moved up by ten percent per digit value with a spring. Only the digits that changed move, so a small update turns the last wheel and a big one turns several.
`log` sorts the totals before and after the update and stores the difference in rank for every row that changed. That number drives the badge, its colour and the direction the rank number flips.
For a few dozen rows, yes. For hundreds, render only the visible window or drop the `layout` prop for rows outside the top group, because every row is measured on each update.
The Live button already pauses the feed by setting `live` to false. You can also collect updates and apply them together every few seconds.
Related: Feature Request Voting Board with Live Reordering, Sortable Data Table with Status Badges and Row Actions, Count Up Stats Row with Rolling Digits.
More tables and lists or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.