A launch board on the day, ranked by live votes, where a fast riser works its way to the top.
registry/demos/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>
);
}
Live preview, examples and props: https://keyframeui.com/components/standings#standings-launch-demo