Rank Promotion
The old tier ring fades while the new tier's ring strokes itself around the badge.
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, 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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Achievements
Medallion tier card changing colour and insignia on qualification.
Related patterns
- Certificate IssueThe border strokes itself around the sheet, the seal settles on, and the name is written last.
- Trophy Shelf AddA new award settles into the first slot while the awards already there slide over to make room.
- Year in Review StatA headline figure counts up over two deliberate seconds, then the line that explains it arrives.