All patterns

Rank Promotion

The old tier ring fades while the new tier's ring strokes itself around the badge.

achievementpremiumelegantautomatic · finite · intermediate · ~1.2s
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.

271 lines · react + motion only
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Rank Badge Promote
 *
 * Moving up a tier. The old ring fades out while the new tier's ring
 * draws itself around the badge from twelve o'clock, the rank glyph
 * gains a bar, and the tier name changes underneath.
 *
 * Drawing the ring rather than crossfading it is what makes this a
 * promotion: the stroke has a start, a direction and a finish, so the
 * new tier is something that gets built around the wearer.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The disc is mixed from the inherited text color; the two tier colours
 * are semantic and stay literal.
 * Works with zero props; tune via `variant`, `from`, `to`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type RankTier = {
  name: string;
  /** How many bars the glyph shows. 1–4 reads cleanly. */
  bars: number;
  /** Tier colour. Semantic, so it stays literal. */
  color: string;
};

export type RankBadgePromoteProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** The tier being left. */
  from?: RankTier;
  /** The tier being reached. */
  to?: RankTier;
  /** Badge diameter in px. */
  size?: number;
  /** Small line above the tier name. */
  eyebrow?: string;
  /** Fires once the new ring has finished drawing. */
  onPromoted?: () => void;
};

type VariantConfig = {
  /** Beat before the promotion, so the old tier is read first. */
  delay: number;
  /** How long the new ring takes to draw. */
  draw: number;
  /** Peak of the disc's single settle, as a scale factor. */
  settle: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Quick, for a tier chip in an account header.
  subtle: { delay: 0.22, draw: 0.5, settle: 1.01 },
  // The all-purpose setting.
  default: { delay: 0.34, draw: 0.72, settle: 1.03 },
  // A slower circuit, for a dedicated status screen.
  playful: { delay: 0.44, draw: 0.98, settle: 1.04 },
};

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

const SILVER: RankTier = { name: "Silver", bars: 2, color: "#8B93A1" };
const GOLD: RankTier = { name: "Gold", bars: 3, color: "#C79A4C" };

/** Rank glyph: stacked chevrons, one per tier bar. Drawn, not typeset,
 *  so nothing here is text that could be caught scaling. */
function Chevrons({ bars }: { bars: number }) {
  return (
    <svg width="34" height="34" viewBox="0 0 34 34" fill="none" aria-hidden>
      {Array.from({ length: bars }, (_, index) => (
        <path
          key={index}
          d={`M9 ${21.5 - index * 6} 17 ${15.5 - index * 6} 25 ${21.5 - index * 6}`}
          stroke="currentColor"
          strokeWidth="2.6"
          strokeLinecap="round"
          strokeLinejoin="round"
        />
      ))}
    </svg>
  );
}

export default function RankBadgePromote({
  variant = "default",
  from = SILVER,
  to = GOLD,
  size = 108,
  eyebrow = "Tier",
  onPromoted,
}: RankBadgePromoteProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const still = !!reduceMotion;

  // Reduced motion: the badge is simply the new tier. Both that and the
  // reset a new run needs are render-time facts, so the run key lives in
  // state and is compared during render; the effect only owns the timer.
  const runKey = `${still}:${cfg.delay}`;
  const [run, setRun] = useState({ key: runKey, promoted: still });
  if (run.key !== runKey) setRun({ key: runKey, promoted: still });
  const promoted = run.key === runKey ? run.promoted : still;

  useEffect(() => {
    if (still) return;
    const timer = setTimeout(
      () => setRun({ key: runKey, promoted: true }),
      cfg.delay * 1000
    );
    return () => clearTimeout(timer);
  }, [still, cfg.delay, runKey]);

  const tier = promoted ? to : from;
  const radius = 44;

  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        gap: 13,
      }}
    >
      <div style={{ position: "relative", width: size, height: size }}>
        <motion.div
          initial={false}
          animate={
            promoted && !still ? { scale: [1, cfg.settle, 1] } : { scale: 1 }
          }
          transition={{
            duration: 0.5,
            times: [0, 0.35, 1],
            delay: cfg.draw * 0.72,
            ease: "easeOut",
          }}
          style={{ width: "100%", height: "100%", lineHeight: 0 }}
        >
          <svg
            viewBox="0 0 100 100"
            width="100%"
            height="100%"
            fill="none"
            role="img"
            aria-label={`${eyebrow}: ${tier.name}`}
          >
            <circle cx="50" cy="50" r={radius - 5} fill={tone(7)} />

            {/* Outgoing ring: it only fades. Animating both rings around
                the circle at once would read as a spinner. */}
            <motion.circle
              cx="50"
              cy="50"
              r={radius}
              stroke={from.color}
              strokeWidth="4.5"
              initial={false}
              animate={{ opacity: promoted ? 0 : 0.85 }}
              transition={{
                duration: still ? 0 : cfg.draw * 0.45,
                ease: "easeOut",
              }}
            />

            {/* Incoming ring: strokes itself from twelve o'clock. */}
            <motion.circle
              cx="50"
              cy="50"
              r={radius}
              stroke={to.color}
              strokeWidth="4.5"
              strokeLinecap="round"
              transform="rotate(-90 50 50)"
              initial={{ pathLength: still ? 1 : 0 }}
              animate={{ pathLength: promoted ? 1 : 0 }}
              transition={{
                duration: still ? 0 : cfg.draw,
                ease: [0.33, 0, 0.15, 1],
              }}
              onAnimationComplete={
                promoted && onPromoted ? () => onPromoted() : undefined
              }
            />
          </svg>
        </motion.div>

        {/* The glyph crossfades in place: the old bars leave upward, the
            new set arrives from below, and neither ever changes size. */}
        <div
          style={{
            position: "absolute",
            inset: 0,
            display: "grid",
            placeItems: "center",
            pointerEvents: "none",
          }}
        >
          <span style={{ position: "relative", width: 34, height: 34 }}>
            <AnimatePresence initial={false}>
              <motion.span
                key={tier.name}
                initial={{ opacity: 0, y: still ? 0 : 7 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: still ? 0 : -7 }}
                transition={{ duration: still ? 0 : 0.34, ease: "easeOut" }}
                style={{
                  position: "absolute",
                  inset: 0,
                  color: tier.color,
                  lineHeight: 0,
                }}
              >
                <Chevrons bars={tier.bars} />
              </motion.span>
            </AnimatePresence>
          </span>
        </div>
      </div>

      <div
        style={{
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          gap: 2,
          textAlign: "center",
        }}
      >
        <span
          style={{
            fontSize: 10,
            fontWeight: 620,
            letterSpacing: "0.09em",
            textTransform: "uppercase",
            color: tone(46),
          }}
        >
          {eyebrow}
        </span>
        <span style={{ position: "relative", height: 22, width: 120 }}>
          <AnimatePresence initial={false}>
            <motion.span
              key={tier.name}
              initial={{ opacity: 0, y: still ? 0 : 6 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: still ? 0 : -6 }}
              transition={{ duration: still ? 0 : 0.3, ease: "easeOut" }}
              style={{
                position: "absolute",
                inset: 0,
                display: "flex",
                alignItems: "center",
                justifyContent: "center",
                fontSize: 16,
                fontWeight: 650,
                letterSpacing: "-0.01em",
                color: tier.color,
              }}
            >
              {tier.name}
            </motion.span>
          </AnimatePresence>
        </span>
      </div>
    </div>
  );
}

About this pattern

Crossing into the next tier. The outgoing ring only fades — animating two rings around the circle at the same time would read as a spinner — while the incoming one draws from twelve o'clock in the new tier's colour, and the badge gives a single three-percent settle as the stroke closes. Drawing rather than crossfading is what makes this a promotion instead of a swap: the stroke has a start, a direction and a finish, so the new standing is something built around the wearer. The rank glyph is stacked chevrons rather than a numeral, so the mark that changes is drawn artwork and no text is ever caught scaling; the tier name below crossfades in a fixed-height slot.

Loyalty tier upgradeMembership status changeSeller rating promotionSupport level 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

    Medallion tier card changing colour and insignia on qualification.

Related patterns