All patterns

Level Up

The points bar runs out of room, the level number rolls over, and the overflow refills from empty.

achievementpremiumenergeticautomatic · finite · advanced · ~1.4s
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.

304 lines · react + motion only
import { useEffect, useRef, useState } from "react";
import {
  animate,
  AnimatePresence,
  motion,
  useMotionValue,
  useReducedMotion,
  useTransform,
} from "motion/react";

/**
 * Vibary · Level Up Progress
 *
 * The bar runs out of room. It fills to the top of the current level,
 * the track acknowledges it once, the level number rolls over, and the
 * overflow carries into the next level's bar from empty.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The track is mixed from the inherited text color; the level accent is
 * semantic and stays literal.
 * Works with zero props; tune via `variant`, `level`, `earned`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type LevelUpProgressProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Level before the gain. */
  level?: number;
  /** Points already banked in that level. */
  start?: number;
  /** Points needed to finish that level. */
  levelCap?: number;
  /** Points needed to finish the next level. */
  nextCap?: number;
  /** Points carried past the boundary into the next level. */
  overflow?: number;
  /** Unit shown after the figures. */
  unit?: string;
  /** Bar color. Semantic, so it stays literal. */
  accent?: string;
  /** Fires once the overflow has settled in the new level. */
  onComplete?: () => void;
};

type VariantConfig = {
  /** Beat before the bar starts moving. */
  delay: number;
  /** Time to run out the current level. */
  fillOut: number;
  /** Time to lay down the carried-over points. */
  fillIn: number;
};

// Tweens, not springs: a points bar that overshoots its value shows a
// total the account does not contain. The curve decelerates hard so the
// last few percent — the ones that trip the level — are readable.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Quick, for a level meter tucked into a sidebar.
  subtle: { delay: 0.05, fillOut: 0.45, fillIn: 0.34 },
  // The all-purpose setting.
  default: { delay: 0.12, fillOut: 0.68, fillIn: 0.48 },
  // A longer run-up for a screen where the level-up is the event.
  playful: { delay: 0.18, fillOut: 0.92, fillIn: 0.62 },
};

const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const SLOT = 19;

/** One digit column: translate and crossfade, constant font size. */
function Digit({ char, still }: { char: string; still: boolean }) {
  return (
    <span
      style={{
        position: "relative",
        display: "inline-block",
        width: "1ch",
        height: SLOT,
        overflow: "hidden",
      }}
    >
      <AnimatePresence initial={false}>
        <motion.span
          key={char}
          initial={{ y: SLOT * 0.8, opacity: 0 }}
          animate={{ y: 0, opacity: 1 }}
          exit={{ y: -SLOT * 0.8, opacity: 0 }}
          transition={{ duration: still ? 0 : 0.34, ease: [0.22, 1, 0.36, 1] }}
          style={{
            position: "absolute",
            inset: 0,
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
          }}
        >
          {char}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

export default function LevelUpProgress({
  variant = "default",
  level = 7,
  start = 1240,
  levelCap = 1500,
  nextCap = 1800,
  overflow = 140,
  unit = "XP",
  accent = "#6E63F5",
  onComplete,
}: LevelUpProgressProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  // New numbers — or a new variant — are a new run, so the promotion has
  // to start over. That reset is a render-time fact: the run key lives in
  // state and is compared during render, which leaves the effect below
  // owning the two animations and nothing else.
  const runKey = [
    reduceMotion,
    start,
    levelCap,
    nextCap,
    overflow,
    cfg.delay,
    cfg.fillOut,
    cfg.fillIn,
  ].join(":");
  const fresh = { key: runKey, promoted: !!reduceMotion, flash: false };
  const [run, setRun] = useState(fresh);
  if (run.key !== runKey) setRun(fresh);
  const promoted = run.key === runKey ? run.promoted : fresh.promoted;
  const flash = run.key === runKey ? run.flash : fresh.flash;

  const fill = useMotionValue(reduceMotion ? overflow / nextCap : start / levelCap);
  // The cap lives in a ref so the readout can switch scales mid-flight
  // without re-creating the transform: one motion value still drives
  // both the bar and the digits, so they can never disagree.
  const capRef = useRef(reduceMotion ? nextCap : levelCap);
  const digits = useTransform(fill, (v) =>
    Math.round(v * capRef.current).toLocaleString("en-US")
  );

  const onCompleteRef = useRef(onComplete);
  useEffect(() => {
    onCompleteRef.current = onComplete;
  }, [onComplete]);

  useEffect(() => {
    // Reduced motion: already promoted, bar already at the carried-over
    // value. The information survives; only the travel is dropped.
    if (reduceMotion) {
      capRef.current = nextCap;
      fill.set(overflow / nextCap);
      return;
    }

    capRef.current = levelCap;
    fill.set(start / levelCap);

    let second: { stop: () => void } | undefined;
    const first = animate(fill, 1, {
      duration: cfg.fillOut,
      delay: cfg.delay,
      ease: [0.33, 0, 0.2, 1],
      onComplete: () => {
        // One acknowledgement at the boundary, then the new level's bar
        // starts from empty — the reset is the level change made visible.
        setRun({ key: runKey, promoted: true, flash: true });
        capRef.current = nextCap;
        fill.set(0);
        second = animate(fill, overflow / nextCap, {
          duration: cfg.fillIn,
          ease: [0.22, 1, 0.36, 1],
          onComplete: () => onCompleteRef.current?.(),
        });
      },
    });

    return () => {
      first.stop();
      second?.stop();
    };
  }, [
    reduceMotion,
    fill,
    start,
    levelCap,
    nextCap,
    overflow,
    cfg.delay,
    cfg.fillOut,
    cfg.fillIn,
    runKey,
  ]);

  const shownLevel = String(promoted ? level + 1 : level);
  const shownCap = (promoted ? nextCap : levelCap).toLocaleString("en-US");

  return (
    <div style={{ width: 288, display: "flex", flexDirection: "column", gap: 10 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
        <span
          style={{
            display: "inline-flex",
            alignItems: "center",
            gap: 5,
            padding: "4px 9px",
            borderRadius: 8,
            fontSize: 12.5,
            fontWeight: 620,
            color: accent,
            background: `color-mix(in srgb, ${accent} 14%, transparent)`,
            fontVariantNumeric: "tabular-nums",
          }}
        >
          Level
          <span style={{ display: "inline-flex" }}>
            {shownLevel.split("").map((char, index) => (
              <Digit key={index} char={char} still={!!reduceMotion} />
            ))}
          </span>
        </span>

        <span
          style={{
            marginLeft: "auto",
            fontSize: 12,
            color: tone(58),
            fontVariantNumeric: "tabular-nums",
          }}
        >
          <motion.span>{digits}</motion.span>
          {` / ${shownCap} ${unit}`}
        </span>
      </div>

      <div
        role="progressbar"
        aria-label={`Level ${promoted ? level + 1 : level} progress`}
        aria-valuemin={0}
        aria-valuemax={nextCap}
        aria-valuenow={overflow}
        style={{
          position: "relative",
          height: 8,
          borderRadius: 999,
          background: tone(11),
          overflow: "hidden",
        }}
      >
        <motion.div
          style={{
            height: "100%",
            borderRadius: 999,
            background: accent,
            transformOrigin: "left center",
            scaleX: fill,
          }}
        />
        {/* A single pass of light across the full track at the moment the
            level tips. It plays once and is gone — not a loop. */}
        <motion.div
          aria-hidden
          initial={false}
          animate={flash ? { opacity: [0, 0.34, 0] } : { opacity: 0 }}
          transition={{ duration: 0.55, times: [0, 0.3, 1], ease: "easeOut" }}
          style={{
            position: "absolute",
            inset: 0,
            borderRadius: 999,
            background: "#FFFFFF",
          }}
        />
      </div>

      <div style={{ minHeight: 16, fontSize: 11.5 }}>
        <AnimatePresence initial={false} mode="wait">
          <motion.span
            key={promoted ? "after" : "before"}
            initial={{ opacity: 0, y: reduceMotion ? 0 : 4 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: reduceMotion ? 0 : -4 }}
            transition={{ duration: reduceMotion ? 0 : 0.24, ease: "easeOut" }}
            style={{
              display: "inline-block",
              color: promoted ? accent : tone(50),
              fontWeight: promoted ? 600 : 500,
            }}
          >
            {promoted
              ? `Level ${level + 1} reached`
              : `${(levelCap - start).toLocaleString("en-US")} ${unit} to Level ${level + 1}`}
          </motion.span>
        </AnimatePresence>
      </div>
    </div>
  );
}

About this pattern

A level boundary is a scale change, and the motion says so. The bar fills to the top of the current level, the track takes one pass of light, the level chip rolls to its next value, and then the bar restarts from empty and lays down only the points that carried across. Resetting to zero in view is the whole idea — it is what tells the reader the axis behind the bar just changed, which a bar that simply kept sliding could never communicate. The fills are tweens rather than springs: a points meter that overshoots draws a total the account does not contain.

Experience pointsLoyalty tier progressSkill level upCourse rank advance

Where it shows up

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

  • 10:15
    Achievements
    First orderUnlocked today
    Five-day streakUnlocked Tue
    Early riserUnlocked last week
    Full monthLocked
    HomeSearchActivityProfile
    Achievements

    Stars meter filling to a tier boundary, then restarting with the carried-over balance.

Related patterns