# 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.

Animated Leaderboard for React: rows race past each other as scores change, like a timing tower. Built with Tailwind CSS; install it with the shadcn CLI. It's a KeyframeUI component: the file lands in your project as source, yours to edit.

- Docs and live preview: https://keyframeui.com/components/standings
- Tags: Data, Chart, Motion
- npm dependencies: clsx, tailwind-merge
- Inspired by: [ByDefault](https://www.bydefault.so)

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/standings.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/standings
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/standings.json
```

## Usage

The main demo shows typical usage.

### 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.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/standings-demo
```

```tsx title="components/standings-demo.tsx"
"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>
  );
}
```

## 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.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/standings-benchmark-demo
```

```tsx title="components/standings-benchmark-demo.tsx"
"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>
  );
}
```

### Launch Day

A launch board on the day, ranked by live votes, where a fast riser works its way to the top.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/standings-launch-demo
```

```tsx title="components/standings-launch-demo.tsx"
"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>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `entries` (required) | `StandingsEntry[]` |  | 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[]` |  | 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` |  | The period to show, controlled. The rows race to it when it changes. |
| `defaultPeriod` | `number` |  | The period to start on, uncontrolled. Defaults to the first when it autoplays, otherwise the last. |
| `onPeriodChange` | `(period: number) => void` |  | Called with the period a visitor picks, or that autoplay reaches. |
| `onOrderChange` | `(ids: string[]) => void` |  | 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` |  | The score that fills a bar. Defaults to the highest score in `entries`. |
| `format` | `(value: number) => string` |  | 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` |  | 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`). |

## What to keep

To adapt it, change its props first. When you do edit `components/ui/standings.tsx`, keep these, which make it feel and behave right:

- 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 (durations, delays, springs and easings) is all in the `TIMING` block at the top of the file; the timing props default to it.

## Source

```tsx title="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>
  );
}
```
