All patterns

Leaderboard Climb

Your row travels up the board while the rows it passes slide down to make the space.

achievementenergeticpremiumautomatic · finite · advanced · ~0.9s
Variant

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.

243 lines · react + motion only
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.

Weekly leaderboardTeam standingsSales ranking boardTournament ladder

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • 10:15
    This week
    1Priya Raman2,480 pts
    2You2,310 pts
    3Marcus Bell2,145 pts
    4Dana Whitfield1,980 pts
    HomeSearchActivityProfile
    Leaderboard

    Board re-sorting with the athlete's own row highlighted as it moves.

Related patterns