Standings
Animated Leaderboard for React
A ranking that races, like a timing tower. The place numbers stay put and the rows slide past them: when a score overtakes the one above, the two rows trade places, the climbing one passing over the other on a soft shadow, and a chip counts the places each has gained. Give it periods and it plays through them week by week, with a timeline to scrub back and forth; give it new scores and the rows race to the new order.
<Standings />The demo follows as you drag.
Top free · Productivity
United States · daily downloads
Daybook rank
#6
- Tally Notes, 11,800
- Northdesk, 9,800
- Kiln Calendar, 8,600
- Pebble, 6,200
- Orbit Focus, 4,900
- Daybook (you), 3,100
"use client";
import * as React from "react";
import { Standings, type StandingsEntry } from "@/components/ui/standings";
// Daybook, a journaling app, over the eight weeks after its relaunch: daily
// downloads in the store's Productivity chart, and the five apps above it.
const WEEKS = ["W1", "W2", "W3", "W4", "W5", "W6", "W7", "W8"];
function AppIcon({ from, to, children }: { from: string; to: string; children: React.ReactNode }) {
return (
<span
className="grid size-full place-items-center text-white shadow-[inset_0_0_0_1px_rgb(255_255_255/0.18)]"
style={{ backgroundImage: `linear-gradient(145deg, ${from}, ${to})` }}
>
<svg viewBox="0 0 20 20" className="size-[18px]" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
{children}
</svg>
</span>
);
}
const APPS: StandingsEntry[] = [
{
id: "daybook",
label: "Daybook",
detail: "Journal & mood",
you: true,
value: [3100, 3900, 5200, 6400, 7900, 9300, 10800, 12600],
icon: (
<AppIcon from="#ff8a3d" to="#ff3d00">
<path d="M5 3.5h8.5a1.5 1.5 0 0 1 1.5 1.5v11.5H6.5A1.5 1.5 0 0 1 5 15V3.5Z" />
<path d="M8 7.5h4M8 10.5h2.5" />
</AppIcon>
),
},
{
id: "tally",
label: "Tally Notes",
detail: "Notes & lists",
value: [11800, 11600, 11900, 11400, 11200, 11000, 10900, 10600],
icon: (
<AppIcon from="#4f8bff" to="#1d4ed8">
<path d="M5.5 6h9M5.5 10h9M5.5 14h5.5" />
</AppIcon>
),
},
{
id: "northdesk",
label: "Northdesk",
detail: "Shared inbox",
value: [9800, 9600, 9100, 9400, 9000, 8800, 8600, 8500],
icon: (
<AppIcon from="#34d399" to="#059669">
<path d="M3.5 10.5 6 5h8l2.5 5.5V15h-13v-4.5Z" />
<path d="M3.5 10.5H7l1 2h4l1-2h3.5" />
</AppIcon>
),
},
{
id: "kiln",
label: "Kiln Calendar",
detail: "Calendar & booking",
value: [8600, 8900, 9300, 8200, 7600, 7300, 7100, 6900],
icon: (
<AppIcon from="#fb7185" to="#e11d48">
<rect x="4" y="5" width="12" height="11" rx="2" />
<path d="M4 8.5h12M7.5 3.5v3M12.5 3.5v3" />
</AppIcon>
),
},
{
id: "pebble",
label: "Pebble",
detail: "Habit tracker",
value: [6200, 6000, 5800, 6100, 6300, 6000, 5900, 5700],
icon: (
<AppIcon from="#a78bfa" to="#6d28d9">
<circle cx="10" cy="10" r="5.5" />
<path d="m7.8 10 1.6 1.6 3-3.2" />
</AppIcon>
),
},
{
id: "orbit",
label: "Orbit Focus",
detail: "Focus timer",
value: [4900, 4700, 4500, 4300, 4100, 3900, 3800, 3600],
icon: (
<AppIcon from="#fbbf24" to="#ea580c">
<circle cx="10" cy="10.5" r="5.5" />
<path d="M10 7.5v3l2 1.5M8.5 3.5h3" />
</AppIcon>
),
},
];
export function StandingsDemo() {
const [place, setPlace] = React.useState(6);
const numeral = React.useRef<HTMLSpanElement>(null);
const onOrderChange = React.useCallback((order: string[]) => setPlace(order.indexOf("daybook") + 1), []);
// The numeral rolls in from below whenever Daybook changes place.
const drawn = React.useRef(place);
React.useEffect(() => {
if (drawn.current === place) return;
drawn.current = place;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
numeral.current?.animate(
[
{ transform: "translateY(40%)", opacity: 0 },
{ transform: "translateY(0)", opacity: 1 },
],
{ duration: 260, easing: "cubic-bezier(0.23, 1, 0.32, 1)" },
);
}, [place]);
return (
<div className="w-full max-w-xl rounded-2xl border border-zinc-950/[0.08] bg-white text-zinc-950 shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-12px_rgb(0_0_0/0.12)] dark:border-white/10 dark:bg-zinc-950 dark:text-zinc-50">
<div className="flex items-start justify-between gap-4 border-b border-zinc-950/[0.07] px-5 pt-4 pb-3.5 dark:border-white/[0.08]">
<div className="min-w-0">
<p className="text-[13px] font-semibold tracking-[-0.01em]">Top free · Productivity</p>
<p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">United States · daily downloads</p>
</div>
<div className="text-right">
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400">Daybook rank</p>
<p className="mt-0.5 overflow-hidden text-3xl leading-none font-semibold tracking-[-0.04em] tabular-nums">
<span className="text-[#ff5a1f]">#</span>
<span ref={numeral} className="inline-block">
{place}
</span>
</p>
</div>
</div>
<Standings
aria-label="Top free apps in Productivity, by week"
entries={APPS}
periods={WEEKS}
loop
onOrderChange={onOrderChange}
className="px-2 pt-1 pb-3 [--standings-surface:#ffffff] dark:[--standings-surface:#09090b]"
/>
</div>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Standings: A ranking that races, like a timing tower. The place numbers stay put and the rows slide past them: when a score overtakes the one above, the two rows trade places, the climbing one passing over the other on a soft shadow, and a chip counts the places each has gained. Give it periods and it plays through them week by week, with a timeline to scrub back and forth; give it new scores and the rows race to the new order. This demo, Category Chart: A journaling app's eight weeks after its relaunch, climbing a store's Productivity chart from sixth to first, with a rank readout that turns over at each pass. 1. Install it: npx shadcn@latest add @keyframeui/standings-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/standings-demo.json instead.) It adds the demo, components/standings-demo.tsx, and the component, components/ui/standings.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <Standings> on its own. 3. These props set its feel. Change them before you change the file: - speed (default 1): How fast the race runs along the timeline, as a multiple of the default pace: each period as it plays, the race to a clicked period, and the loop's hold, rewind and rest. `2` is twice as fast. - stiffness (default 260): Stiffness of the critically damped spring a row rides to its new place when it overtakes another. Higher lands sooner; at the default it lands in about 0.4s. - change (default true): Show a chip with the places each row has gained since the first period (or, without periods, since the standings first rendered). 4. Don't change, in components/ui/standings.tsx: - One frame loop (`createStandings`) owns every moving value and writes transforms and text straight to the DOM; React renders the rows once and only hears back when a period settles or the order changes. - The place numbers never move, the rows do: each slides by `transform` on a critically damped spring (damping `2 * sqrt(stiffness)`), so a swap never overshoots. - A row going up passes over the other: z-index 3 while rising, 1 while falling, 2 at rest, and its shadow's opacity follows its upward speed. - The loop sleeps once every row has landed; playing pauses off screen and on a hidden tab and picks up where it was. - Reduced motion: no autoplay, it rests on the last period, and a row that changes place jumps there and fades in; the timeline still steps and scrubs. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/standings.md
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/standings.tsx and installs clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/standingsInstall the dependencies
npm i clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
components/ui/standings.tsx"use client"; import * as React from "react"; import { cn } from "@/lib/utils"; /** * Keep: * - One frame loop (`createStandings`) owns every moving value and writes transforms and text * straight to the DOM; React renders the rows once and only hears back when a period settles * or the order changes. * - The place numbers never move, the rows do: each slides by `transform` on a critically damped * spring (damping `2 * sqrt(stiffness)`), so a swap never overshoots. * - A row going up passes over the other: z-index 3 while rising, 1 while falling, 2 at rest, and * its shadow's opacity follows its upward speed. * - The loop sleeps once every row has landed; playing pauses off screen and on a hidden tab and * picks up where it was. * - Reduced motion: no autoplay, it rests on the last period, and a row that changes place jumps * there and fades in; the timeline still steps and scrubs. */ const TIMING = { /** How fast the race runs, as a multiple of the default pace below (the `speed` prop). */ speed: 1, /** A row's critically damped spring to its new place: lands in ~0.4s (the `stiffness` prop). */ stiffness: 260, /** On screen before autoplay starts, in ms. */ start: 450, // At the default pace; `speed` divides these. /** A period while it plays, in ms. A number `autoplay` replaces it. */ period: 1000, /** Racing to a clicked period, in ms: `base` plus `per` for each period crossed, up to `max`. */ jump: { base: 520, per: 160, max: 1600 }, /** Settling on the nearest period after a drag, in ms. */ settle: 240, /** On the last period before `loop` rewinds, in ms. */ hold: 2600, /** The rewind, in ms. */ rewind: 1100, /** On the first period before a loop plays again, in ms. */ rest: 700, // At any pace. /** Time constant of the scrub following the pointer, in seconds. */ follow: 0.06, /** Each score gliding to new `entries`, in ms. */ blend: 600, /** The change chip's pop, in ms, and its curve. */ pop: { duration: 220, easing: "cubic-bezier(0.23, 1, 0.32, 1)" }, /** A row fading in at its new place under reduced motion, in ms, and its curve. */ fade: { duration: 200, easing: "ease-out" }, /** The spring's integration step, in seconds. */ substep: 0.004, /** The longest step a frame may take, in seconds, so a slow frame can't jump the race. */ maxStep: 0.064, /** A place number taking the accent under your row, as a Tailwind class. */ rank: "duration-200", /** The play button's hover, as a Tailwind class. */ button: "duration-150", /** A timeline tick filling as the race passes it, as a Tailwind class. */ tick: "duration-200", /** A period label showing as the current one, as a Tailwind class. */ label: "duration-200", } as const; /* ───────────────────────────────────────────────────────── * STANDINGS STORYBOARD * * rest a ranking in fixed slots, like a race's timing * tower: the place numbers never move, the rows do. * Each row has its mark, its name, a bar, its score * and a chip with the places it has gained. Your row * is tinted in the accent * view with `periods`, once 35% of it is on screen it * waits 450ms, then plays through them, a second a * period. Scores ease from one period into the next * and settle on each, so the race moves week by week * overtake when a score passes the one above it, the two rows * trade places on a critically damped spring (lands * in about 0.4s). The row going up passes over the * other, raised on a soft shadow; the row going down * slides under it. The place number it lands on stays * put, and turns the accent when the row is yours * chip the places gained since the first period pop in, * green going up and red going down, 220ms * scrub the timeline under it: drag and the scores follow * the pointer, with a detent at each period, and the * rows swap as they cross; let go and it settles on * the nearest one in 240ms. A click races to that * period: 520ms plus 160ms for each one it crosses * data new `entries` (a benchmark tab, a live vote): each * score glides from where it is to its new value in * 600ms, and the rows re-race on the way * loop `loop`: 2.6s on the last period, then the race * rewinds in 1.1s, the rows sliding back down, rests * 0.7s and plays again * sleep the frame loop stops once every row has landed. * Playing pauses off screen and on a hidden tab, and * picks up where it was * keys the timeline is a slider: arrows step a period, * Home and End go to either end. The button plays, * pauses and replays * reduced no autoplay: it rests on the last period, the * result. Rows don't travel; a row that changes place * fades in at the new one over 200ms. The timeline * still steps and scrubs * ───────────────────────────────────────────────────────── */ const STANDINGS = { start: { amount: 0.35 }, // share on screen before autoplay's wait starts land: { distance: 0.25, speed: 4 }, // px, px/s: a row nearer and slower than this has landed lift: 600, // px/s of upward speed at which a passing row's shadow is at full strength } as const; const clamp = (v: number, lo: number, hi: number) => (v < lo ? lo : v > hi ? hi : v); /** cubic-bezier(0.65, 0, 0.35, 1): races and rewinds. */ const inOut = (p: number) => (p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2); /** An ease-out cubic: a score gliding to new data starts at once. */ const out = (p: number) => 1 - Math.pow(1 - p, 3); /** Within a period: leave one, settle on the next. Also the scrub's detents. */ const smooth = (f: number) => f * f * (3 - 2 * f); const numbers = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }); const defaultFormat = (value: number) => numbers.format(value); function ordinal(n: number) { const suffix = ["th", "st", "nd", "rd"]; const v = n % 100; return n + (suffix[(v - 20) % 10] ?? suffix[v] ?? suffix[0]); } /** An entry's score at `t`, a position along the periods (1.5 is halfway from the second to the third). */ function valueAt(value: number | number[], t: number, count: number) { if (typeof value === "number") return value; if (!value.length) return 0; if (count < 2) return value[value.length - 1]; const i = Math.floor(t); const a = value[Math.min(i, value.length - 1)]; const b = value[Math.min(i + 1, value.length - 1)]; return a + (b - a) * smooth(t - i); } /** Ids from first place to last. Ties keep the order they had. */ function rank(ids: string[], score: (id: string) => number, best: Best, before: Map<string, number>) { return [...ids].sort((a, b) => { const d = best === "lowest" ? score(a) - score(b) : score(b) - score(a); return d || (before.get(a) ?? 0) - (before.get(b) ?? 0); }); } const indexOf = (order: string[]) => new Map(order.map((id, i) => [id, i])); function peak(entries: StandingsEntry[]) { let top = 0; for (const e of entries) for (const v of typeof e.value === "number" ? [e.value] : e.value) top = Math.max(top, v); return top || 1; } const REDUCED = "(prefers-reduced-motion: reduce)"; const subscribeReduced = (onChange: () => void) => { const query = window.matchMedia(REDUCED); query.addEventListener("change", onChange); return () => query.removeEventListener("change", onChange); }; /** Reduced motion from the media query, false on the server and in the hydration render. */ function useReduced() { return React.useSyncExternalStore( subscribeReduced, () => window.matchMedia(REDUCED).matches, () => false, ); } export interface StandingsEntry { /** Stable id: a row keeps its element (and its place in the race) across data changes. */ id: string; /** The name in the row. */ label: string; /** A second line under the name, such as the developer or the model's size. */ detail?: string; /** A small mark before the name: an app icon, a logo, an avatar. */ icon?: React.ReactNode; /** The score. One number, or one per period. */ value: number | number[]; /** Your row: tinted in the accent, tagged "You" and named when the standings are announced. */ you?: boolean; } type Best = "highest" | "lowest"; type Status = "idle" | "playing" | "ended"; export interface StandingsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> { /** The rows, in any order: they're ranked by score. */ entries: StandingsEntry[]; /** Labels for the periods an entry's `value` array runs over ("W1", "Mar"). With two or more, a timeline under the rows plays and scrubs through them. */ periods?: string[]; /** The period to show, controlled. The rows race to it when it changes. */ period?: number; /** The period to start on, uncontrolled. Defaults to the first when it autoplays, otherwise the last. */ defaultPeriod?: number; /** Called with the period a visitor picks, or that autoplay reaches. */ onPeriodChange?: (period: number) => void; /** Called with the ids in their new order, the moment a row overtakes another. */ onOrderChange?: (ids: string[]) => void; /** Play through the periods once the standings scroll into view. A number sets the ms per period. */ autoplay?: boolean | number; /** After the last period, hold, rewind and play again. */ loop?: boolean; /** Which end of the scores ranks first: `"lowest"` for times and prices. */ best?: Best; /** The score that fills a bar. Defaults to the highest score in `entries`. */ max?: number; /** How a score is written. Defaults to a rounded number with thousands separators. */ format?: (value: number) => string; /** Show a chip with the places each row has gained since the first period (or, without periods, since it first rendered). */ change?: boolean; /** How fast the race runs along the timeline, as a multiple of the default pace: playing each period, racing to a clicked one, and the loop's hold, rewind and rest. `2` is twice as fast. */ speed?: number; /** Stiffness of the critically damped spring a row rides to its new place. Higher lands sooner. */ stiffness?: number; } type Parts = { el: HTMLElement; bar: HTMLElement | null; value: HTMLElement | null; chip: HTMLElement | null; count: HTMLElement | null; lift: HTMLElement | null; }; type Row = Parts & { y: number; drawn: number; v: number; slot: number; shown: number; text: string; scale: number; delta: number; z: number; lifted: number; }; type Data = { entries: StandingsEntry[]; count: number; best: Best; max: number; format: (value: number) => string; you: string | null; }; type Listeners = { onPeriod: (period: number, reason: "auto" | "user") => void; onStatus: (status: Status) => void; onOrder: (ids: string[]) => void; }; type Mode = | { kind: "idle" } | { kind: "play" } | { kind: "jump"; from: number; to: number; elapsed: number; duration: number; then?: () => void } | { kind: "hold"; left: number; then: () => void } | { kind: "drag"; target: number }; /* ── Engine ─────────────────────────────────────────────── */ // One frame loop owns every moving value: the position along the periods, // each score, each row's spring, the bars, the chips and the timeline. It // writes transforms and text straight to the DOM; React only renders the // rows once and hears back when a period settles. function createStandings() { let root: HTMLElement | null = null; const rows = new Map<string, Row>(); let ranks: HTMLElement[] = []; let ticks: HTMLElement[] = []; let labels: HTMLElement[] = []; let fill: HTMLElement | null = null; let thumb: HTMLElement | null = null; let data: Data | null = null; let t = 0; let rowH = 0; let order: string[] = []; let baseline = new Map<string, number>(); let blend: { from: Map<string, number>; elapsed: number } | null = null; let mode: Mode = { kind: "idle" }; let auto = false; // playing on its own (autoplay or loop), until a visitor takes over let looping = false; let periodMs: number = TIMING.period; let pace: number = TIMING.speed; // `speed`: every move along the timeline runs at 1 / pace of its time let stiffness: number = TIMING.stiffness; let reduced = false; let visible = true; let paused: Mode | null = null; // what was running when it went off screen let raf = 0; let last = 0; let reported = -1; let passed = -1; let current = -1; let youSlot = -1; let status: Status = "idle"; let listeners: Listeners | null = null; const lastIndex = () => Math.max(0, (data?.count ?? 1) - 1); const timeline = () => (data?.count ?? 0) > 1; function score(id: string, entries: StandingsEntry[], at: number) { const e = entries.find((x) => x.id === id); return e ? valueAt(e.value, at, data?.count ?? 0) : 0; } function setStatus(next: Status) { if (next === status) return; status = next; listeners?.onStatus(next); } function wake() { if (!raf && root) raf = requestAnimationFrame(frame); } function frame(now: number) { raf = 0; const dt = last ? Math.min((now - last) / 1000, TIMING.maxStep) : 1 / 60; last = now; let busy = advance(dt); busy = place(dt) || busy; if (busy) raf = requestAnimationFrame(frame); else last = 0; } /** Moves `t` along for whatever is running. Returns whether anything still is. */ function advance(dt: number) { const end = lastIndex(); switch (mode.kind) { case "play": { t = Math.min(end, t + (dt * 1000 * pace) / periodMs); if (t >= end) finished(); return true; } case "jump": { mode.elapsed += dt * 1000; const p = mode.duration <= 0 ? 1 : clamp(mode.elapsed / mode.duration, 0, 1); t = mode.from + (mode.to - mode.from) * inOut(p); if (p >= 1) { t = mode.to; const then = mode.then; mode = { kind: "idle" }; if (then) then(); else settle("user"); } return true; } case "hold": { mode.left -= dt * 1000; if (mode.left <= 0) { const then = mode.then; mode = { kind: "idle" }; then(); } return true; } case "drag": { const target = mode.target; t += (target - t) * (1 - Math.exp(-dt / TIMING.follow)); if (Math.abs(target - t) < 0.0005) t = target; return t !== target; } default: return false; } } function finished() { const end = lastIndex(); t = end; mode = { kind: "idle" }; report("auto"); if (auto && looping) { mode = { kind: "hold", left: reduced ? Infinity : TIMING.hold / pace, then: () => { mode = { kind: "jump", from: end, to: 0, elapsed: 0, duration: TIMING.rewind / pace, then: () => { report("auto"); mode = { kind: "hold", left: TIMING.rest / pace, then: () => (mode = { kind: "play" }) }; }, }; }, }; return; } auto = false; setStatus("ended"); } /** Tells React which period is showing. */ function report(reason: "auto" | "user") { const p = Math.round(t); if (p === reported) return; reported = p; listeners?.onPeriod(p, reason); } function settle(reason: "auto" | "user") { report(reason); setStatus(timeline() && Math.round(t) >= lastIndex() ? "ended" : "idle"); } /** Scores, order, springs and every write for this frame. Returns whether anything still moves. */ function place(dt: number) { if (!data) return false; let busy = false; const { entries, count, best, max, format } = data; let k = 1; if (blend) { blend.elapsed += dt * 1000; k = out(clamp(blend.elapsed / TIMING.blend, 0, 1)); if (blend.elapsed >= TIMING.blend) blend = null; else busy = true; } for (const e of entries) { const row = rows.get(e.id); if (!row) continue; const target = valueAt(e.value, t, count); const from = blend?.from.get(e.id); row.shown = from === undefined ? target : from + (target - from) * k; } const next = rank( entries.map((e) => e.id), (id) => rows.get(id)?.shown ?? 0, best, indexOf(order), ); if (next.join("\u0000") !== order.join("\u0000")) { const before = indexOf(order); order = next; next.forEach((id, slot) => { const row = rows.get(id); if (!row) return; row.slot = slot; if (reduced && before.has(id) && before.get(id) !== slot) { row.el.animate([{ opacity: 0.25 }, { opacity: 1 }], TIMING.fade); } }); listeners?.onOrder(next); } // The place number under your row takes the accent. const you = data.you ? order.indexOf(data.you) : -1; if (you !== youSlot) { ranks[youSlot]?.removeAttribute("data-you"); ranks[you]?.setAttribute("data-you", ""); youSlot = you; } const k2 = stiffness; const c2 = 2 * Math.sqrt(k2); for (const row of rows.values()) { const target = row.slot * rowH; if (reduced || !rowH) { row.y = target; row.v = 0; } else if (Math.abs(row.y - target) > STANDINGS.land.distance || Math.abs(row.v) > STANDINGS.land.speed) { const n = Math.max(1, Math.ceil(dt / TIMING.substep)); const h = dt / n; for (let i = 0; i < n; i++) { row.v += (-k2 * (row.y - target) - c2 * row.v) * h; row.y += row.v * h; } busy = true; } else { row.y = target; row.v = 0; } if (!(Math.abs(row.y - row.drawn) <= 0.01)) { row.drawn = row.y; row.el.style.transform = `translateY(${row.y.toFixed(2)}px)`; } // A row on its way up passes over the others, on a shadow as strong as its speed. const lifted = row.v < 0 ? clamp(-row.v / STANDINGS.lift, 0, 1) : 0; if (Math.abs(lifted - row.lifted) > 0.01 || (lifted === 0 && row.lifted !== 0)) { row.lifted = lifted; if (row.lift) row.lift.style.opacity = lifted.toFixed(3); } const z = row.v < -20 ? 3 : row.v > 20 ? 1 : 2; if (z !== row.z) { row.z = z; row.el.style.zIndex = String(z); } const scale = clamp(row.shown / max, 0, 1); if (Math.abs(scale - row.scale) > 0.0005) { row.scale = scale; if (row.bar) row.bar.style.transform = `translateX(${((scale - 1) * 100).toFixed(2)}%)`; } const text = format(row.shown); if (text !== row.text) { row.text = text; if (row.value) row.value.textContent = text; } } // Places gained since the baseline order. for (const [id, row] of rows) { const base = baseline.get(id); const delta = base === undefined ? 0 : base - row.slot; if (delta === row.delta) continue; row.delta = delta; if (row.chip) row.chip.dataset.dir = delta > 0 ? "up" : delta < 0 ? "down" : "flat"; if (row.count) row.count.textContent = delta === 0 ? "–" : String(Math.abs(delta)); if (row.chip && !reduced) { row.chip.animate( [ { transform: "scale(0.8)", opacity: 0.3 }, { transform: "scale(1)", opacity: 1 }, ], TIMING.pop, ); } } paintTimeline(); if (mode.kind === "play") report("auto"); return busy; } function paintTimeline() { if (!timeline()) return; const p = clamp(t / lastIndex(), 0, 1); if (fill) fill.style.transform = `scaleX(${p.toFixed(4)})`; if (thumb) thumb.style.transform = `translateX(${(p * 100).toFixed(3)}%)`; const reached = Math.floor(t + 0.001); if (reached !== passed) { passed = reached; ticks.forEach((tick, i) => tick.toggleAttribute("data-passed", i <= reached)); } const near = Math.round(t); if (near !== current) { labels[current]?.removeAttribute("data-current"); labels[near]?.setAttribute("data-current", ""); current = near; } } function measure() { const first = rows.values().next().value; const h = first ? first.el.offsetHeight : 0; if (!h || h === rowH) return; rowH = h; // A new row height is a new layout, not a move: everything lands at once. for (const row of rows.values()) { row.y = row.slot * rowH; row.v = 0; } wake(); } function stop() { auto = false; paused = null; mode = { kind: "idle" }; } return { listen(next: Listeners) { listeners = next; }, /** Picks up the rendered rows and the data they show. Called after every render that changes either. */ sync(el: HTMLElement, next: Data, start: number) { const first = !data; root = el; const changed = !data || data.count !== next.count || data.best !== next.best || data.max !== next.max || data.entries.length !== next.entries.length || next.entries.some((e) => { const prev = data?.entries.find((x) => x.id === e.id); if (!prev) return true; const a = typeof prev.value === "number" ? [prev.value] : prev.value; const b = typeof e.value === "number" ? [e.value] : e.value; return a.length !== b.length || a.some((v, i) => v !== b[i]); }); const reformat = data?.format !== next.format; if (first) t = start; if (changed && !first) { blend = { from: new Map([...rows].map(([id, row]) => [id, row.shown])), elapsed: 0 }; } data = next; const ids = next.entries.map((e) => e.id); if (first || changed) { const at0 = rank(ids, (id) => score(id, next.entries, 0), next.best, indexOf(ids)); if (next.count > 1) baseline = indexOf(at0); else if (first) baseline = indexOf(rank(ids, (id) => score(id, next.entries, t), next.best, indexOf(ids))); } if (first) order = rank(ids, (id) => score(id, next.entries, t), next.best, indexOf(ids)); else order = order.filter((id) => ids.includes(id)).concat(ids.filter((id) => !order.includes(id))); const live = new Set<string>(); el.querySelectorAll<HTMLElement>("[data-standings-row]").forEach((rowEl) => { const id = rowEl.dataset.id; if (!id) return; live.add(id); const parts: Parts = { el: rowEl, bar: rowEl.querySelector("[data-bar]"), value: rowEl.querySelector("[data-value]"), chip: rowEl.querySelector("[data-chip]"), count: rowEl.querySelector("[data-count]"), lift: rowEl.querySelector("[data-lift]"), }; const row = rows.get(id); if (row) { Object.assign(row, parts); return; } const slot = Math.max(0, order.indexOf(id)); const shown = score(id, next.entries, t); rows.set(id, { ...parts, y: slot * rowH, drawn: NaN, v: 0, slot, shown, text: next.format(shown), scale: clamp(shown / next.max, 0, 1), delta: Number(rowEl.querySelector<HTMLElement>("[data-chip]")?.dataset.delta ?? 0), z: 2, lifted: 0, }); }); for (const id of [...rows.keys()]) if (!live.has(id)) rows.delete(id); ranks = [...el.querySelectorAll<HTMLElement>("[data-rank]")]; ticks = [...el.querySelectorAll<HTMLElement>("[data-tick]")]; labels = [...el.querySelectorAll<HTMLElement>("[data-period-label]")]; fill = el.querySelector("[data-fill]"); thumb = el.querySelector("[data-thumb]"); youSlot = ranks.findIndex((r) => r.hasAttribute("data-you")); passed = current = -1; // React may have rewritten a row's styles or text on this render; draw it all again. for (const row of rows.values()) { row.drawn = NaN; row.scale = -1; row.z = -1; row.lifted = -1; if (reformat) row.text = ""; } measure(); if (first) { reported = Math.round(t); setStatus(timeline() && Math.round(t) >= lastIndex() && !auto ? "ended" : "idle"); } // Draw now, before the browser paints this render. place(0); wake(); }, resize() { measure(); }, /** `speed` and `stiffness`; a race already under way picks them up on its next frame. */ setFeel(nextSpeed: number, nextStiffness: number) { pace = nextSpeed; stiffness = nextStiffness; wake(); }, /** `rest`: nothing has played or been touched yet, so reduced motion shows the result at once. */ setReduced(next: boolean, rest: boolean) { reduced = next; if (next && timeline() && (mode.kind === "play" || (rest && mode.kind === "idle" && t !== lastIndex()))) { stop(); t = lastIndex(); settle("auto"); } wake(); }, /** Plays on its own when it first comes into view. A visitor's input since cancels it. */ autoplay(ms: number, loop: boolean, touched: boolean) { if (touched || !timeline()) return; periodMs = ms; looping = loop; auto = true; if (reduced) { auto = false; t = lastIndex(); settle("auto"); wake(); return; } mode = t >= lastIndex() ? { kind: "jump", from: t, to: 0, elapsed: 0, duration: 0, then: () => (mode = { kind: "play" }) } : { kind: "play" }; setStatus("playing"); wake(); }, toggle(ms: number, loop: boolean) { if (status === "playing") { stop(); settle("user"); return; } periodMs = ms; looping = loop; auto = loop; const play = () => (mode = { kind: "play" }); if (t >= lastIndex() - 0.001) { mode = { kind: "jump", from: t, to: 0, elapsed: 0, duration: reduced ? 0 : TIMING.rewind / pace, then: play }; } else play(); setStatus("playing"); wake(); }, /** Races to a period: a click, a key, a controlled `period`. */ goTo(to: number) { if (!data) return; const target = clamp(Math.round(to), 0, lastIndex()); if (mode.kind === "jump" && mode.to === target && !mode.then) return; if (mode.kind === "idle" && t === target) return; stop(); const crossed = Math.abs(target - t); const duration = reduced ? 0 : crossed < 1 ? TIMING.settle / pace : Math.min(TIMING.jump.max, TIMING.jump.base + TIMING.jump.per * (crossed - 1)) / pace; mode = { kind: "jump", from: t, to: target, elapsed: 0, duration }; setStatus("idle"); wake(); }, /** The pointer on the timeline, as a position along the periods. */ drag(to: number) { stop(); mode = { kind: "drag", target: clamp(to, 0, lastIndex()) }; if (reduced) t = mode.target; setStatus("idle"); wake(); }, release() { if (mode.kind !== "drag") return; const target = Math.round(mode.target); mode = { kind: "jump", from: t, to: target, elapsed: 0, duration: reduced ? 0 : TIMING.settle / pace }; wake(); }, position() { return t; }, setVisible(next: boolean) { if (next === visible) return; visible = next; if (!next && (mode.kind === "play" || mode.kind === "hold" || (mode.kind === "jump" && mode.then))) { paused = mode; mode = { kind: "idle" }; } else if (next && paused) { mode = paused; paused = null; wake(); } }, disconnect() { if (raf) cancelAnimationFrame(raf); raf = 0; last = 0; root = null; }, }; } /* ── Glyphs ─────────────────────────────────────────────── */ function PlayIcon({ status }: { status: Status }) { if (status === "playing") { return ( <svg viewBox="0 0 12 12" className="size-3" fill="currentColor" aria-hidden> <rect x="2.5" y="2" width="2.5" height="8" rx="0.75" /> <rect x="7" y="2" width="2.5" height="8" rx="0.75" /> </svg> ); } if (status === "ended") { return ( <svg viewBox="0 0 12 12" className="size-3" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden> <path d="M2.5 6a3.5 3.5 0 1 0 1.1-2.55" strokeLinecap="round" /> <path d="M2.4 1.8v2.1h2.1" strokeLinecap="round" strokeLinejoin="round" /> </svg> ); } return ( <svg viewBox="0 0 12 12" className="ml-px size-3" fill="currentColor" aria-hidden> <path d="M3.5 2.2v7.6a.6.6 0 0 0 .9.5l6-3.8a.6.6 0 0 0 0-1L4.4 1.7a.6.6 0 0 0-.9.5Z" /> </svg> ); } /* ── Standings ──────────────────────────────────────────── */ export function Standings({ entries, periods, period, defaultPeriod, onPeriodChange, onOrderChange, autoplay = true, loop = false, best = "highest", max, format = defaultFormat, change = true, speed = TIMING.speed, stiffness = TIMING.stiffness, className, ...props }: StandingsProps) { const rootRef = React.useRef<HTMLDivElement>(null); const trackRef = React.useRef<HTMLDivElement>(null); const touched = React.useRef(false); const reduced = useReduced(); const count = periods?.length ?? 0; const timeline = count > 1; const lastIndex = Math.max(0, count - 1); const controlled = period !== undefined; const periodMs = typeof autoplay === "number" ? autoplay : TIMING.period; const top = React.useMemo(() => max ?? peak(entries), [max, entries]); const you = entries.find((e) => e.you)?.id ?? null; const [engine] = React.useState(createStandings); const [start] = React.useState(() => clamp(Math.round(period ?? defaultPeriod ?? (autoplay ? 0 : lastIndex)), 0, lastIndex), ); // What the server renders, and what React keeps writing: the rows as they // stand at the first period shown. The engine moves them from there. const [first] = React.useState(() => { const ids = entries.map((e) => e.id); const at = (id: string, t: number) => { const e = entries.find((x) => x.id === id); return e ? valueAt(e.value, t, count) : 0; }; const order = rank(ids, (id) => at(id, start), best, indexOf(ids)); const base = indexOf(count > 1 ? rank(ids, (id) => at(id, 0), best, indexOf(ids)) : order); const rows = new Map( ids.map((id) => { const slot = order.indexOf(id); const value = at(id, start); return [id, { slot, text: format(value), scale: clamp(value / top, 0, 1), delta: (base.get(id) ?? slot) - slot }]; }), ); return { order, rows }; }); const [shown, setShown] = React.useState(start); const [status, setStatus] = React.useState<Status>( timeline && !autoplay && start >= lastIndex ? "ended" : "idle", ); const [ranking, setRanking] = React.useState(first.order); const [announcement, setAnnouncement] = React.useState(""); // The engine calls these from its frame loop. React.useEffect(() => { engine.listen({ onPeriod: (p, reason) => { setShown(p); if (reason === "user" || !controlled) onPeriodChange?.(p); }, onStatus: setStatus, onOrder: (ids) => { setRanking(ids); onOrderChange?.(ids); }, }); }, [engine, controlled, onPeriodChange, onOrderChange]); React.useLayoutEffect(() => { const el = rootRef.current; if (!el) return; engine.sync(el, { entries, count, best, max: top, format, you }, start); }, [engine, entries, count, best, top, format, you, start]); React.useEffect(() => { const el = rootRef.current; if (!el) return; const ro = new ResizeObserver(() => engine.resize()); ro.observe(el); let timer = 0; let seen = false; let inView = true; const io = new IntersectionObserver( ([entry]) => { inView = entry.isIntersecting; engine.setVisible(inView && !document.hidden); if (inView && entry.intersectionRatio >= STANDINGS.start.amount && !seen) { seen = true; if (autoplay && !controlled) { timer = window.setTimeout(() => engine.autoplay(periodMs, loop, touched.current), TIMING.start); } } }, { threshold: [0, STANDINGS.start.amount] }, ); io.observe(el); const onVisibility = () => engine.setVisible(inView && !document.hidden); document.addEventListener("visibilitychange", onVisibility); return () => { window.clearTimeout(timer); ro.disconnect(); io.disconnect(); document.removeEventListener("visibilitychange", onVisibility); }; }, [engine, autoplay, controlled, periodMs, loop]); React.useEffect( () => engine.setReduced(reduced, autoplay !== false && !controlled && !touched.current), [engine, reduced, autoplay, controlled], ); React.useLayoutEffect( () => engine.setFeel(Math.max(0.05, speed), Math.max(1, stiffness)), [engine, speed, stiffness], ); React.useEffect(() => { if (controlled) engine.goTo(period); }, [engine, controlled, period]); React.useEffect(() => () => engine.disconnect(), [engine]); // Announce your place when a visitor moves the timeline or the data changes. const youEntry = entries.find((e) => e.you); const youPlace = youEntry ? ranking.indexOf(youEntry.id) + 1 : 0; const lastAnnounced = React.useRef({ place: youPlace, shown }); React.useEffect(() => { if (!youEntry || !touched.current) return; if (lastAnnounced.current.place === youPlace && lastAnnounced.current.shown === shown) return; lastAnnounced.current = { place: youPlace, shown }; const when = periods?.[shown]; const id = window.setTimeout( () => setAnnouncement(`${when ? `${when}: ` : ""}${youEntry.label}, ${ordinal(youPlace)} of ${entries.length}.`), 0, ); return () => window.clearTimeout(id); }, [youEntry, youPlace, shown, periods, entries.length]); const take = () => { touched.current = true; }; const positionAt = (clientX: number) => { const rect = trackRef.current?.getBoundingClientRect(); if (!rect || !rect.width) return 0; return clamp((clientX - rect.left) / rect.width, 0, 1) * lastIndex; }; const drag = React.useRef<{ id: number; x: number; moved: boolean } | null>(null); const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => { if (e.button !== 0) return; take(); e.currentTarget.setPointerCapture(e.pointerId); drag.current = { id: e.pointerId, x: e.clientX, moved: false }; engine.goTo(positionAt(e.clientX)); }; const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => { const d = drag.current; if (!d || d.id !== e.pointerId) return; if (!d.moved && Math.abs(e.clientX - d.x) < 3) return; d.moved = true; engine.drag(positionAt(e.clientX)); }; const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => { const d = drag.current; if (!d || d.id !== e.pointerId) return; drag.current = null; if (d.moved) engine.release(); onPeriodChange?.(Math.round(positionAt(e.clientX))); }; const step = (to: number) => { take(); const target = clamp(to, 0, lastIndex); engine.goTo(target); onPeriodChange?.(target); }; const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => { const t = engine.position(); const keys: Record<string, number> = { ArrowRight: Math.floor(t + 0.001) + 1, ArrowUp: Math.floor(t + 0.001) + 1, PageUp: Math.floor(t + 0.001) + 1, ArrowLeft: Math.ceil(t - 0.001) - 1, ArrowDown: Math.ceil(t - 0.001) - 1, PageDown: Math.ceil(t - 0.001) - 1, Home: 0, End: lastIndex, }; if (!(e.key in keys)) return; e.preventDefault(); step(keys[e.key]); }; const byId = new Map(entries.map((e) => [e.id, e])); const startP = lastIndex ? start / lastIndex : 0; return ( <div ref={rootRef} role="group" className={cn( "@container/standings relative [--standings-accent:#ff5a1f] [--standings-line:color-mix(in_oklab,currentColor_9%,transparent)] [--standings-row:3.25rem] [--standings-surface:#ffffff] dark:[--standings-surface:#09090b]", className, )} {...props} > <div className="flex" aria-hidden> <div className="w-9 shrink-0 @[24rem]/standings:w-11"> {entries.map((e, i) => ( <div key={e.id} data-rank="" {...(first.rows.get(you ?? "")?.slot === i ? { "data-you": "" } : {})} className={`flex h-[var(--standings-row)] items-center justify-center text-[13px] font-medium tabular-nums opacity-45 shadow-[inset_0_-1px_0_var(--standings-line)] transition-[color,opacity] ${TIMING.rank} data-[you]:text-[var(--standings-accent)] data-[you]:opacity-100`} > {i + 1} </div> ))} </div> <div className="relative min-w-0 flex-1" style={{ height: `calc(var(--standings-row) * ${entries.length})` }}> {entries.map((entry) => { const seed = first.rows.get(entry.id); const slot = seed?.slot ?? entries.indexOf(entry); const scale = seed?.scale ?? clamp(valueAt(entry.value, start, count) / top, 0, 1); const delta = seed?.delta ?? 0; return ( <div key={entry.id} data-standings-row="" data-id={entry.id} className={cn( "absolute inset-x-0 top-0 flex h-[var(--standings-row)] items-center gap-3 pr-2 pl-1 shadow-[inset_0_-1px_0_var(--standings-line)] will-change-transform @[24rem]/standings:pr-3", entry.you ? "bg-[color-mix(in_oklab,var(--standings-accent)_9%,var(--standings-surface))]" : "bg-[var(--standings-surface)]", )} style={{ transform: `translateY(calc(var(--standings-row) * ${slot}))`, zIndex: 2 }} > <span data-lift="" className="pointer-events-none absolute inset-0 opacity-0 shadow-[0_10px_24px_-10px_rgb(0_0_0/0.35),0_2px_6px_-2px_rgb(0_0_0/0.12)] will-change-[opacity] dark:shadow-[0_12px_28px_-8px_rgb(0_0_0/0.9),0_0_0_1px_rgb(255_255_255/0.08)]" /> {entry.you ? ( <span className="absolute inset-y-0 left-0 w-0.5 bg-[var(--standings-accent)]" /> ) : null} {entry.icon ? ( <span className="relative grid size-8 shrink-0 place-items-center overflow-hidden rounded-[9px]"> {entry.icon} </span> ) : null} <span className="relative min-w-0 flex-1 leading-tight"> <span className="flex min-w-0 items-center gap-1.5"> <span className={cn("truncate text-sm", entry.you ? "font-semibold" : "font-medium")}> {entry.label} </span> {entry.you ? ( <span className="hidden shrink-0 rounded-full bg-[var(--standings-accent)] px-1.5 py-px text-[10px] font-semibold tracking-wide text-white uppercase @[24rem]/standings:inline"> You </span> ) : null} </span> {entry.detail ? ( <span className="mt-0.5 hidden truncate text-xs opacity-55 @[20rem]/standings:block"> {entry.detail} </span> ) : null} </span> <span className="relative hidden h-1.5 w-[clamp(3.5rem,22cqi,9rem)] shrink-0 overflow-hidden rounded-full bg-current/10 @[26rem]/standings:block"> <span data-bar="" className={cn( "absolute inset-0 rounded-full will-change-transform", entry.you ? "bg-[var(--standings-accent)]" : "bg-current/35", )} style={{ transform: `translateX(${((scale - 1) * 100).toFixed(2)}%)` }} /> </span> <span data-value="" className="relative min-w-[6ch] text-right text-sm font-medium tabular-nums"> {seed?.text ?? format(valueAt(entry.value, start, count))} </span> {change ? ( <span data-chip="" data-delta={delta} data-dir={delta > 0 ? "up" : delta < 0 ? "down" : "flat"} className="group/chip relative hidden w-9 shrink-0 items-center justify-end gap-0.5 text-xs font-semibold tabular-nums data-[dir=down]:text-rose-600 data-[dir=flat]:opacity-35 data-[dir=up]:text-emerald-600 @[18rem]/standings:inline-flex dark:data-[dir=down]:text-rose-400 dark:data-[dir=up]:text-emerald-400" > <svg viewBox="0 0 8 8" className="size-2 group-data-[dir=down]/chip:rotate-180 group-data-[dir=flat]/chip:hidden" fill="currentColor" > <path d="M4 1.2 7.2 6.4H.8Z" /> </svg> <span data-count="">{delta === 0 ? "–" : Math.abs(delta)}</span> </span> ) : null} </div> ); })} </div> </div> {timeline ? ( <div className="mt-3 flex items-start gap-3 pr-1 pl-0.5"> <button type="button" onClick={() => { take(); engine.toggle(periodMs, loop); }} aria-label={status === "playing" ? "Pause" : status === "ended" ? "Replay" : "Play"} className={`grid size-8 shrink-0 place-items-center rounded-full border border-current/15 transition-colors ${TIMING.button} hover:bg-current/5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--standings-accent)]`} > <PlayIcon status={status} /> </button> <div className="min-w-0 flex-1"> <div role="slider" tabIndex={0} aria-label="Period" aria-valuemin={0} aria-valuemax={lastIndex} aria-valuenow={shown} aria-valuetext={periods?.[shown]} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} onKeyDown={onKeyDown} className="group/slider relative h-8 cursor-pointer touch-none outline-none select-none" > <div ref={trackRef} className="absolute inset-x-1.5 top-1/2 h-0"> <span className="absolute inset-x-0 -top-px h-[3px] overflow-hidden rounded-full bg-current/10"> <span data-fill="" className="absolute inset-0 origin-left bg-[var(--standings-accent)] will-change-transform" style={{ transform: `scaleX(${startP})` }} /> </span> {periods?.map((label, i) => ( <span key={label + i} data-tick="" {...(i <= start ? { "data-passed": "" } : {})} className={`absolute -top-[3.5px] size-2 -translate-x-1/2 rounded-full border-2 border-[var(--standings-surface)] bg-[color-mix(in_oklab,currentColor_22%,var(--standings-surface))] transition-colors ${TIMING.tick} data-[passed]:bg-[var(--standings-accent)]`} style={{ left: `${(i / lastIndex) * 100}%` }} /> ))} <span data-thumb="" className="pointer-events-none absolute inset-x-0 top-0 will-change-transform" style={{ transform: `translateX(${startP * 100}%)` }} > <span className="absolute top-0 left-0 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-[2.5px] border-[var(--standings-accent)] bg-[var(--standings-surface)] shadow-[0_1px_3px_rgb(0_0_0/0.2)] ring-[var(--standings-accent)]/30 group-focus-visible/slider:ring-4" /> </span> </div> </div> <div className="relative mx-1.5 -mt-1 h-4 text-[11px] leading-4 tabular-nums" aria-hidden> {periods?.map((label, i) => ( <span key={label + i} data-period-label="" {...(i === start ? { "data-current": "" } : {})} className={cn( `absolute top-0 whitespace-nowrap opacity-0 transition-opacity ${TIMING.label} data-[current]:font-medium data-[current]:opacity-100 @[26rem]/standings:opacity-45 @[26rem]/standings:data-[current]:opacity-100`, i === 0 ? "" : i === lastIndex ? "-translate-x-full" : "-translate-x-1/2", )} style={{ left: `${(i / lastIndex) * 100}%` }} > {label} </span> ))} </div> </div> </div> ) : null} <ol className="sr-only"> {ranking.map((id) => { const e = byId.get(id); if (!e) return null; return ( <li key={id}> {e.label} {e.you ? " (you)" : ""}, {format(valueAt(e.value, shown, count))} </li> ); })} </ol> <p className="sr-only" aria-live="polite"> {announcement} </p> </div> ); }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
Examples
Finished sections built with Standings. Each one installs whole, with the component included.
Benchmark Tabs
A model leaderboard on four benchmarks. Switch the tab and the same six models race to their new order.
Open leaderboard
Run of Nov 2
Accuracy on 1,400 multi-step problems
- Halcyon Pro, 84.1%
- Gannet XL, 81.7%
- Wren 3 (you), 79.4%
- Osprey 70B, 76.2%
- Marlin 2, 72.8%
- Petrel Mini, 61.5%
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Standings, type StandingsEntry } from "@/components/ui/standings";
const CYCLE = 3400; // ms each benchmark stays up while the tabs play by themselves
type Model = { id: string; label: string; detail: string; color: string; you?: boolean };
// Kestrel Labs' Wren 3 against five other open and hosted models, on four
// benchmarks from the same evaluation run.
const MODELS: Model[] = [
{ id: "wren", label: "Wren 3", detail: "Kestrel Labs · 32B", color: "#ff5a1f", you: true },
{ id: "halcyon", label: "Halcyon Pro", detail: "Halcyon · hosted", color: "#2563eb" },
{ id: "gannet", label: "Gannet XL", detail: "Northgate · 120B", color: "#7c3aed" },
{ id: "osprey", label: "Osprey 70B", detail: "Fernhill AI · 70B", color: "#059669" },
{ id: "marlin", label: "Marlin 2", detail: "Tidewater · 8×22B", color: "#db2777" },
{ id: "petrel", label: "Petrel Mini", detail: "Fernhill AI · 8B", color: "#d97706" },
];
type Benchmark = {
id: string;
label: string;
unit: string;
best?: "highest" | "lowest";
max?: number;
format: (v: number) => string;
scores: Record<string, number>;
};
const BENCHMARKS: Benchmark[] = [
{
id: "reasoning",
label: "Reasoning",
unit: "Accuracy on 1,400 multi-step problems",
max: 100,
format: (v) => `${v.toFixed(1)}%`,
scores: { halcyon: 84.1, gannet: 81.7, wren: 79.4, osprey: 76.2, marlin: 72.8, petrel: 61.5 },
},
{
id: "coding",
label: "Coding",
unit: "Tasks passed on the first try",
max: 100,
format: (v) => `${v.toFixed(1)}%`,
scores: { wren: 71.3, halcyon: 69.8, marlin: 66, gannet: 64.2, osprey: 58.9, petrel: 49.1 },
},
{
id: "speed",
label: "Speed",
unit: "Output tokens per second",
format: (v) => `${Math.round(v)}/s`,
scores: { petrel: 412, wren: 268, marlin: 231, osprey: 174, halcyon: 122, gannet: 96 },
},
{
id: "price",
label: "Price",
unit: "Dollars per million output tokens, lowest first",
best: "lowest",
format: (v) => `$${v.toFixed(2)}`,
scores: { petrel: 0.4, wren: 0.9, marlin: 1.6, osprey: 2.1, gannet: 6, halcyon: 12 },
},
];
function Mark({ color, label }: { color: string; label: string }) {
return (
<span
className="grid size-full place-items-center text-[13px] font-semibold text-white"
style={{ backgroundImage: `linear-gradient(150deg, ${color}cc, ${color})` }}
>
{label[0]}
</span>
);
}
const reducedQuery = "(prefers-reduced-motion: reduce)";
export function StandingsBenchmarkDemo() {
const [active, setActive] = React.useState(0);
const [picked, setPicked] = React.useState(false);
const tabs = React.useRef<(HTMLButtonElement | null)[]>([]);
const benchmark = BENCHMARKS[active];
// The tabs play by themselves until a visitor picks one.
React.useEffect(() => {
if (picked || window.matchMedia(reducedQuery).matches) return;
const id = window.setTimeout(() => setActive((i) => (i + 1) % BENCHMARKS.length), CYCLE);
return () => window.clearTimeout(id);
}, [active, picked]);
const entries = React.useMemo<StandingsEntry[]>(
() =>
MODELS.map((m) => ({
id: m.id,
label: m.label,
detail: m.detail,
you: m.you,
value: benchmark.scores[m.id],
icon: <Mark color={m.color} label={m.label} />,
})),
[benchmark],
);
const pick = (i: number) => {
setPicked(true);
setActive(i);
};
const onKeyDown = (e: React.KeyboardEvent) => {
const step = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (!step) return;
e.preventDefault();
const next = (active + step + BENCHMARKS.length) % BENCHMARKS.length;
pick(next);
tabs.current[next]?.focus();
};
return (
<div className="w-full max-w-xl rounded-2xl border border-zinc-950/[0.08] bg-white text-zinc-950 shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-12px_rgb(0_0_0/0.12)] dark:border-white/10 dark:bg-zinc-950 dark:text-zinc-50">
<div className="border-b border-zinc-950/[0.07] px-5 pt-4 pb-4 dark:border-white/[0.08]">
<div className="flex items-baseline justify-between gap-3">
<p className="text-[13px] font-semibold tracking-[-0.01em]">Open leaderboard</p>
<p className="text-xs text-zinc-500 dark:text-zinc-400">Run of Nov 2</p>
</div>
<div
role="tablist"
aria-label="Benchmark"
onKeyDown={onKeyDown}
className="mt-3 grid grid-cols-4 gap-1 rounded-full bg-zinc-100 p-1 dark:bg-white/[0.06]"
>
{BENCHMARKS.map((b, i) => (
<button
key={b.id}
ref={(el) => {
tabs.current[i] = el;
}}
type="button"
role="tab"
aria-selected={i === active}
tabIndex={i === active ? 0 : -1}
onClick={() => pick(i)}
className={cn(
"h-8 rounded-full text-xs font-medium transition-[background-color,color,box-shadow] duration-150 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#ff5a1f]",
i === active
? "bg-white text-zinc-950 shadow-[0_1px_2px_rgb(0_0_0/0.08),0_0_0_1px_rgb(0_0_0/0.04)] dark:bg-zinc-800 dark:text-white"
: "text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white",
)}
>
{b.label}
</button>
))}
</div>
<p className="mt-3 text-xs text-zinc-500 dark:text-zinc-400">{benchmark.unit}</p>
</div>
<Standings
role="tabpanel"
aria-label={`${benchmark.label} leaderboard`}
entries={entries}
best={benchmark.best}
max={benchmark.max}
format={benchmark.format}
change={false}
className="px-2 pt-1 pb-2 [--standings-surface:#ffffff] dark:[--standings-surface:#09090b]"
/>
</div>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Standings: A ranking that races, like a timing tower. The place numbers stay put and the rows slide past them: when a score overtakes the one above, the two rows trade places, the climbing one passing over the other on a soft shadow, and a chip counts the places each has gained. Give it periods and it plays through them week by week, with a timeline to scrub back and forth; give it new scores and the rows race to the new order. This demo, Benchmark Tabs: A model leaderboard on four benchmarks. Switch the tab and the same six models race to their new order. 1. Install it: npx shadcn@latest add @keyframeui/standings-benchmark-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/standings-benchmark-demo.json instead.) It adds the demo, components/standings-benchmark-demo.tsx, and the component, components/ui/standings.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <Standings> on its own. 3. These props set its feel. Change them before you change the file: - speed (default 1): How fast the race runs along the timeline, as a multiple of the default pace: each period as it plays, the race to a clicked period, and the loop's hold, rewind and rest. `2` is twice as fast. - stiffness (default 260): Stiffness of the critically damped spring a row rides to its new place when it overtakes another. Higher lands sooner; at the default it lands in about 0.4s. - change (default true): Show a chip with the places each row has gained since the first period (or, without periods, since the standings first rendered). 4. Don't change, in components/ui/standings.tsx: - One frame loop (`createStandings`) owns every moving value and writes transforms and text straight to the DOM; React renders the rows once and only hears back when a period settles or the order changes. - The place numbers never move, the rows do: each slides by `transform` on a critically damped spring (damping `2 * sqrt(stiffness)`), so a swap never overshoots. - A row going up passes over the other: z-index 3 while rising, 1 while falling, 2 at rest, and its shadow's opacity follows its upward speed. - The loop sleeps once every row has landed; playing pauses off screen and on a hidden tab and picks up where it was. - Reduced motion: no autoplay, it rests on the last period, and a row that changes place jumps there and fades in; the timeline still steps and scrubs. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/standings.md
Launch Day
A launch board on the day, ranked by live votes, where a fast riser works its way to the top.
Launching today on Dawnlist
Ranked by votes · resets at midnight
Live · 9:12
- Parcel Pigeon, 412
- Quietly, 388
- Moss, 351
- Inkwell (you), 296
- Tidy Tabs, 240
- Brightwave, 198
"use client";
import * as React from "react";
import { Standings, type StandingsEntry } from "@/components/ui/standings";
const TICK = 900; // ms between vote updates
const DAY = 30; // updates before the board starts a new day
type Product = { id: string; label: string; detail: string; from: string; to: string; votes: number; pace: [number, number]; you?: boolean };
// Launch day on Dawnlist, a board for new products. Inkwell launches fourth
// and gathers votes faster than the products above it.
const PRODUCTS: Product[] = [
{ id: "parcel", label: "Parcel Pigeon", detail: "Shipping labels in one click", from: "#38bdf8", to: "#0369a1", votes: 412, pace: [5, 13] },
{ id: "quietly", label: "Quietly", detail: "Noise cancelling for calls", from: "#a3e635", to: "#4d7c0f", votes: 388, pace: [5, 14] },
{ id: "moss", label: "Moss", detail: "Plant care, on a schedule", from: "#4ade80", to: "#15803d", votes: 351, pace: [4, 12] },
{ id: "inkwell", label: "Inkwell", detail: "An AI editor for long documents", from: "#ff8a3d", to: "#ff3d00", votes: 296, pace: [17, 29], you: true },
{ id: "tidy", label: "Tidy Tabs", detail: "Your tabs, sorted by project", from: "#c084fc", to: "#7e22ce", votes: 240, pace: [3, 11] },
{ id: "brightwave", label: "Brightwave", detail: "Podcast clips for social", from: "#f472b6", to: "#be185d", votes: 198, pace: [3, 10] },
];
const START = Object.fromEntries(PRODUCTS.map((p) => [p.id, p.votes]));
/** A small deterministic random, so every day plays out the same way. */
function random(seed: number) {
const x = Math.sin(seed * 12.9898) * 43758.5453;
return x - Math.floor(x);
}
function Mark({ from, to, label }: { from: string; to: string; label: string }) {
return (
<span
className="grid size-full place-items-center text-[13px] font-semibold text-white"
style={{ backgroundImage: `linear-gradient(150deg, ${from}, ${to})` }}
>
{label[0]}
</span>
);
}
const clock = (tick: number) => {
const minutes = 9 * 60 + 12 + tick * 17;
return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, "0")}`;
};
export function StandingsLaunchDemo() {
const ref = React.useRef<HTMLDivElement>(null);
const [tick, setTick] = React.useState(0);
const [votes, setVotes] = React.useState(START);
// Votes come in while the board is on screen.
React.useEffect(() => {
const el = ref.current;
if (!el) return;
let timer = 0;
let seen = false;
let n = 0;
let current = START;
const run = () => {
window.clearInterval(timer);
timer = window.setInterval(() => {
n += 1;
if (n > DAY) {
n = 0;
current = START;
} else {
const next: Record<string, number> = {};
for (const p of PRODUCTS) {
// Inkwell's early rush settles once it's had its morning.
const [lo, hi] = p.you && n > 14 ? [6, 15] : p.pace;
next[p.id] = current[p.id] + Math.round(lo + (hi - lo) * random(n * 31 + p.votes));
}
current = next;
}
setTick(n);
setVotes(current);
}, TICK);
};
const io = new IntersectionObserver(([entry]) => {
seen = entry.isIntersecting;
if (seen && !document.hidden) run();
else window.clearInterval(timer);
});
io.observe(el);
const onVisibility = () => (seen && !document.hidden ? run() : window.clearInterval(timer));
document.addEventListener("visibilitychange", onVisibility);
return () => {
window.clearInterval(timer);
io.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
const entries = React.useMemo<StandingsEntry[]>(
() =>
PRODUCTS.map((p) => ({
id: p.id,
label: p.label,
detail: p.detail,
you: p.you,
value: votes[p.id],
icon: <Mark from={p.from} to={p.to} label={p.label} />,
})),
[votes],
);
return (
<div
ref={ref}
className="w-full max-w-xl rounded-2xl border border-zinc-950/[0.08] bg-white text-zinc-950 shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-12px_rgb(0_0_0/0.12)] dark:border-white/10 dark:bg-zinc-950 dark:text-zinc-50"
>
<div className="flex items-center justify-between gap-3 border-b border-zinc-950/[0.07] px-5 py-4 dark:border-white/[0.08]">
<div className="min-w-0">
<p className="text-[13px] font-semibold tracking-[-0.01em]">Launching today on Dawnlist</p>
<p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">Ranked by votes · resets at midnight</p>
</div>
<p className="flex shrink-0 items-center gap-2 rounded-full border border-zinc-950/[0.08] py-1 pr-2.5 pl-2 text-xs font-medium tabular-nums dark:border-white/10">
<span className="size-1.5 rounded-full bg-rose-500" aria-hidden />
Live · {clock(tick)}
</p>
</div>
<Standings
aria-label="Today's launches, ranked by votes"
entries={entries}
className="px-2 pt-1 pb-2 [--standings-surface:#ffffff] dark:[--standings-surface:#09090b]"
/>
</div>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Standings: A ranking that races, like a timing tower. The place numbers stay put and the rows slide past them: when a score overtakes the one above, the two rows trade places, the climbing one passing over the other on a soft shadow, and a chip counts the places each has gained. Give it periods and it plays through them week by week, with a timeline to scrub back and forth; give it new scores and the rows race to the new order. This demo, Launch Day: A launch board on the day, ranked by live votes, where a fast riser works its way to the top. 1. Install it: npx shadcn@latest add @keyframeui/standings-launch-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/standings-launch-demo.json instead.) It adds the demo, components/standings-launch-demo.tsx, and the component, components/ui/standings.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <Standings> on its own. 3. These props set its feel. Change them before you change the file: - speed (default 1): How fast the race runs along the timeline, as a multiple of the default pace: each period as it plays, the race to a clicked period, and the loop's hold, rewind and rest. `2` is twice as fast. - stiffness (default 260): Stiffness of the critically damped spring a row rides to its new place when it overtakes another. Higher lands sooner; at the default it lands in about 0.4s. - change (default true): Show a chip with the places each row has gained since the first period (or, without periods, since the standings first rendered). 4. Don't change, in components/ui/standings.tsx: - One frame loop (`createStandings`) owns every moving value and writes transforms and text straight to the DOM; React renders the rows once and only hears back when a period settles or the order changes. - The place numbers never move, the rows do: each slides by `transform` on a critically damped spring (damping `2 * sqrt(stiffness)`), so a swap never overshoots. - A row going up passes over the other: z-index 3 while rising, 1 while falling, 2 at rest, and its shadow's opacity follows its upward speed. - The loop sleeps once every row has landed; playing pauses off screen and on a hidden tab and picks up where it was. - Reduced motion: no autoplay, it rests on the last period, and a row that changes place jumps there and fades in; the timeline still steps and scrubs. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/standings.md
Props
| Prop | Type | Default | Description |
|---|---|---|---|
entries Required | StandingsEntry[] | None | The rows, in any order: they're ranked by score. Each is { id, label, detail?, icon?, value, you? }, where value is one number or one per period, and you tints the row in the accent and tags it "You". |
periods | string[] | None | Labels for the periods an entry's value array runs over ("W1", "Mar"). With two or more, a timeline under the rows plays and scrubs through them. |
period | number | None | The period to show, controlled. The rows race to it when it changes. |
defaultPeriod | number | None | The period to start on, uncontrolled. Defaults to the first when it autoplays, otherwise the last. |
onPeriodChange | (period: number) => void | None | Called with the period a visitor picks, or that autoplay reaches. |
onOrderChange | (ids: string[]) => void | None | Called with the ids in their new order, the moment a row overtakes another. Use it to keep a rank readout in step with the race. |
autoplay | boolean | number | true | Play through the periods once the standings scroll into view. A number sets the ms per period (1000 by default). Under reduced motion it shows the last period instead. |
loop | boolean | false | After the last period, hold for 2.6s, rewind and play again. |
speed | number | 1 | How fast the race runs along the timeline, as a multiple of the default pace: each period as it plays, the race to a clicked period, and the loop's hold, rewind and rest. 2 is twice as fast. |
stiffness | number | 260 | Stiffness of the critically damped spring a row rides to its new place when it overtakes another. Higher lands sooner; at the default it lands in about 0.4s. |
best | "highest" | "lowest" | "highest" | Which end of the scores ranks first: "lowest" for times and prices. |
max | number | None | The score that fills a bar. Defaults to the highest score in entries. |
format | (value: number) => string | None | How a score is written. Defaults to a rounded number with thousands separators. |
change | boolean | true | Show a chip with the places each row has gained since the first period (or, without periods, since the standings first rendered). |
className | string | None | Extra classes for the root. Rows are opaque so they can pass over each other: match them to your card with --standings-surface (default white, #09090b in dark), and set the accent with --standings-accent (default #ff5a1f) and the row height with --standings-row (default 3.25rem). |
entriesRequiredType
StandingsEntry[]The rows, in any order: they're ranked by score. Each is
{ id, label, detail?, icon?, value, you? }, wherevalueis one number or one per period, andyoutints the row in the accent and tags it "You".periodsType
string[]Labels for the periods an entry's
valuearray runs over ("W1", "Mar"). With two or more, a timeline under the rows plays and scrubs through them.periodType
numberThe period to show, controlled. The rows race to it when it changes.
defaultPeriodType
numberThe period to start on, uncontrolled. Defaults to the first when it autoplays, otherwise the last.
onPeriodChangeType
(period: number) => voidCalled with the period a visitor picks, or that autoplay reaches.
onOrderChangeType
(ids: string[]) => voidCalled with the ids in their new order, the moment a row overtakes another. Use it to keep a rank readout in step with the race.
autoplayType
boolean | numberDefaulttruePlay through the periods once the standings scroll into view. A number sets the ms per period (1000 by default). Under reduced motion it shows the last period instead.
loopType
booleanDefaultfalseAfter the last period, hold for 2.6s, rewind and play again.
speedType
numberDefault1How fast the race runs along the timeline, as a multiple of the default pace: each period as it plays, the race to a clicked period, and the loop's hold, rewind and rest.
2is twice as fast.stiffnessType
numberDefault260Stiffness of the critically damped spring a row rides to its new place when it overtakes another. Higher lands sooner; at the default it lands in about 0.4s.
bestType
"highest" | "lowest"Default"highest"Which end of the scores ranks first:
"lowest"for times and prices.maxType
numberThe score that fills a bar. Defaults to the highest score in
entries.formatType
(value: number) => stringHow a score is written. Defaults to a rounded number with thousands separators.
changeType
booleanDefaulttrueShow a chip with the places each row has gained since the first period (or, without periods, since the standings first rendered).
classNameType
stringExtra classes for the root. Rows are opaque so they can pass over each other: match them to your card with
--standings-surface(default white,#09090bin dark), and set the accent with--standings-accent(default#ff5a1f) and the row height with--standings-row(default3.25rem).