Leaderboard Climb
Your row travels up the board while the rows it passes slide down to make the space.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useMemo, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Leaderboard Rank Climb
*
* Your row moves up the board and the rows it passes slide down to make
* the space. Shared layout does the work, so the board never re-renders
* as a different board — every row keeps its identity while the order
* around it changes, which is what makes the climb legible instead of
* just being a new list.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Rows are mixed from the inherited text color, so the board reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `entries`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type LeaderboardEntry = {
id: string;
name: string;
score: number;
/** Marks the viewer's own row. At most one entry should set this. */
you?: boolean;
};
export type LeaderboardRankClimbProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Board order before the climb, top first. */
entries?: LeaderboardEntry[];
/** Index the viewer's row ends at, zero-based. */
landsAt?: number;
/** Highlight color for the viewer's row. Semantic, so it stays literal. */
accent?: string;
/** Fires once the board has settled in its new order. */
onSettled?: () => void;
};
type VariantConfig = {
/** Beat before the reorder, so the starting position is read. */
delay: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// All three springs are damped above 0.9. A leaderboard row that
// overshoots its slot momentarily shows the wrong standing, and rows
// that wobble past each other are impossible to follow.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
subtle: {
delay: 0.28,
spring: { type: "spring", stiffness: 520, damping: 46 },
},
default: {
delay: 0.42,
spring: { type: "spring", stiffness: 380, damping: 38 },
},
playful: {
delay: 0.56,
spring: { type: "spring", stiffness: 280, damping: 32 },
},
};
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const DEFAULT_ENTRIES: LeaderboardEntry[] = [
{ id: "ao", name: "Amara Osei", score: 4820 },
{ id: "lb", name: "Lars Bergman", score: 4415 },
{ id: "nk", name: "Noor Karim", score: 4290 },
{ id: "you", name: "You", score: 4610, you: true },
{ id: "tf", name: "Tomás Ferreira", score: 3980 },
];
const initials = (name: string) =>
name
.split(" ")
.map((part) => part[0])
.slice(0, 2)
.join("")
.toUpperCase();
/** Rank number in a fixed slot: crossfades in place so a row changing
* standing never changes width and never drags the name with it. */
function Rank({ value, still }: { value: number; still: boolean }) {
return (
<span
style={{
position: "relative",
width: 16,
height: 18,
flex: "none",
fontVariantNumeric: "tabular-nums",
}}
>
<AnimatePresence initial={false}>
<motion.span
key={value}
initial={{ opacity: 0, y: still ? 0 : 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: still ? 0 : -5 }}
transition={{ duration: still ? 0 : 0.22, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
fontSize: 12,
fontWeight: 600,
opacity: 0.5,
}}
>
{value}
</motion.span>
</AnimatePresence>
</span>
);
}
export default function LeaderboardRankClimb({
variant = "default",
entries = DEFAULT_ENTRIES,
landsAt = 1,
accent = "#4F7BF7",
onSettled,
}: LeaderboardRankClimbProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const still = !!reduceMotion;
const finalOrder = useMemo(() => {
const mover = entries.find((entry) => entry.you) ?? entries[entries.length - 1];
const rest = entries.filter((entry) => entry !== mover);
const at = Math.min(Math.max(landsAt, 0), rest.length);
return [...rest.slice(0, at), mover, ...rest.slice(at)];
}, [entries, landsAt]);
// Reduced motion: the board is simply in its settled order. A rank is
// information; the travel was the presentation of it. That, and the
// reset a new run needs, are render-time facts — holding the run key in
// state settles them in the same pass and leaves the effect owning the
// one thing that genuinely is asynchronous: the timer.
const runKey = `${still}:${cfg.delay}`;
const [run, setRun] = useState({ key: runKey, climbed: still });
if (run.key !== runKey) setRun({ key: runKey, climbed: still });
const climbed = run.key === runKey ? run.climbed : still;
useEffect(() => {
if (still) return;
const timer = setTimeout(
() => setRun({ key: runKey, climbed: true }),
cfg.delay * 1000
);
return () => clearTimeout(timer);
}, [still, cfg.delay, runKey]);
const order = climbed ? finalOrder : entries;
return (
<div
style={{ width: 300, display: "flex", flexDirection: "column", gap: 2 }}
>
{order.map((entry, index) => {
const mine = !!entry.you;
return (
<motion.div
key={entry.id}
layout={!still}
transition={cfg.spring}
onLayoutAnimationComplete={
mine && onSettled ? () => onSettled() : undefined
}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "9px 11px",
borderRadius: 11,
background: mine
? `color-mix(in srgb, ${accent} ${climbed ? 13 : 7}%, transparent)`
: "transparent",
boxShadow: mine ? `inset 0 0 0 1px ${tone(9)}` : "none",
}}
>
<Rank value={index + 1} still={still} />
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
width: 26,
height: 26,
flex: "none",
borderRadius: 999,
fontSize: 10.5,
fontWeight: 640,
letterSpacing: "0.02em",
background: mine
? `color-mix(in srgb, ${accent} 22%, transparent)`
: tone(10),
color: mine ? accent : tone(62),
}}
>
{initials(entry.name)}
</span>
{/* `layout="position"` moves the text without ever letting the
layout engine scale it — a name that stretches mid-climb
reads as a rendering bug, not as motion. */}
<motion.span
layout={!still ? "position" : false}
style={{
fontSize: 13,
fontWeight: mine ? 640 : 520,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{entry.name}
</motion.span>
<motion.span
layout={!still ? "position" : false}
style={{
marginLeft: "auto",
fontSize: 12.5,
fontWeight: 600,
fontVariantNumeric: "tabular-nums",
color: mine ? accent : tone(60),
}}
>
{entry.score.toLocaleString("en-US")}
</motion.span>
</motion.div>
);
})}
</div>
);
}About this pattern
A standings change told as one continuous move rather than as a new list. Each row keeps its React key across the re-sort, so shared layout animates every row from where it was to where it now belongs; your row rises, the rows it overtakes fall past it, and nothing is destroyed and rebuilt. Two details keep it readable. Names and totals use position-only layout animation so text is translated and never stretched by the layout engine. And the rank number crossfades inside a fixed-width slot, so a row changing standing does not change width and drag its own name sideways. The springs are damped well above the wobble threshold — rows that overshoot their slots show the wrong standing, however briefly.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Leaderboard
Board re-sorting with the athlete's own row highlighted as it moves.
Related patterns
- Badge UnlockAn earned badge lands and one band of light crosses its face — a single pass, then still.
- Challenge CompleteA seal presses onto the finished challenge card and settles a couple of degrees off square.
- Goal Ring CloseAn activity ring runs out the last of its gap, the caps meet, and the total settles once.