All patterns

Badge Unlock

An earned badge lands and one band of light crosses its face — a single pass, then still.

achievementpremiumenergeticautomatic · finite · intermediate · ~1.1s
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.

227 lines · react + motion only
import { useId } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Badge Unlock Reveal
 *
 * An earned badge lands, and one band of light crosses its face. One
 * pass, then the object is simply there — the celebration is in the
 * quality of the light, not in the quantity of the movement.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Neutrals are mixed from the inherited text color, so the caption reads
 * correctly on a light page and on a dark one; the badge metal is a
 * literal color because it is the subject, not a surface.
 * Works with zero props; tune via `variant`, `size`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type BadgeUnlockRevealProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Badge width in px. */
  size?: number;
  /** Badge face color. The darker edge is mixed from it. */
  accent?: string;
  /** Small line above the badge name. */
  eyebrow?: string;
  /** The badge's name. */
  title?: string;
  /** What earned it. */
  note?: string;
  /** Fires once the light has finished crossing the face. */
  onComplete?: () => void;
};

type VariantConfig = {
  /** How far under full size the badge starts. */
  scaleFrom: number;
  /** Slight tilt on entry, straightened by the same spring. */
  rotateFrom: number;
  spring: { type: "spring"; stiffness: number; damping: number };
  /** Beat before the light starts, so the landing is seen first. */
  sweepDelay: number;
  sweepDuration: number;
  labelDelay: number;
};

// Every spring here is damped at or above 0.81, so the badge settles
// once and stops. A badge that bounces twice reads as a game reward;
// this one has to survive being shown to the same person every week.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Barely a landing. For a badge that appears inside a busy profile.
  subtle: {
    scaleFrom: 0.94,
    rotateFrom: 0,
    spring: { type: "spring", stiffness: 460, damping: 42 },
    sweepDelay: 0.16,
    sweepDuration: 0.55,
    labelDelay: 0.2,
  },
  // The all-purpose setting: a short drop, then the light.
  default: {
    scaleFrom: 0.86,
    rotateFrom: 0,
    spring: { type: "spring", stiffness: 380, damping: 34 },
    sweepDelay: 0.22,
    sweepDuration: 0.7,
    labelDelay: 0.3,
  },
  // Longer travel and a small tilt to straighten out of. The extra
  // energy comes from distance, never from a second rebound.
  playful: {
    scaleFrom: 0.78,
    rotateFrom: -6,
    spring: { type: "spring", stiffness: 340, damping: 30 },
    sweepDelay: 0.26,
    sweepDuration: 0.82,
    labelDelay: 0.36,
  },
};

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` keeps captions legible in either theme. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const HEX = "M48 3.5 84.5 24.5 84.5 66.5 48 87.5 11.5 66.5 11.5 24.5Z";
const STAR =
  "M48 27.5 52.6 39.4 65.4 40.1 55.5 48.2 58.7 60.6 48 53.7 37.3 60.6 40.5 48.2 30.6 40.1 43.4 39.4Z";

export default function BadgeUnlockReveal({
  variant = "default",
  size = 104,
  accent = "#C79A4C",
  eyebrow = "Badge unlocked",
  title = "Power User",
  note = "50 documents shipped this quarter",
  onComplete,
}: BadgeUnlockRevealProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const uid = useId();
  const faceId = `${uid}-face`;
  const sweepId = `${uid}-sweep`;
  const clipId = `${uid}-clip`;

  const edge = `color-mix(in srgb, ${accent} 68%, #1B1206)`;
  const still = reduceMotion;

  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        gap: 14,
        textAlign: "center",
      }}
    >
      <motion.div
        initial={
          still
            ? { opacity: 0 }
            : { opacity: 0, scale: cfg.scaleFrom, rotate: cfg.rotateFrom }
        }
        animate={still ? { opacity: 1 } : { opacity: 1, scale: 1, rotate: 0 }}
        transition={
          still
            ? { duration: 0.2, ease: "easeOut" }
            : { ...cfg.spring, opacity: { duration: 0.18, ease: "easeOut" } }
        }
        style={{ width: size, height: size, lineHeight: 0 }}
      >
        <svg
          viewBox="0 0 96 96"
          width="100%"
          height="100%"
          fill="none"
          role="img"
          aria-label={`${eyebrow}: ${title}`}
        >
          <defs>
            <linearGradient id={faceId} x1="0.2" y1="0" x2="0.8" y2="1">
              <stop offset="0%" stopColor={accent} />
              <stop offset="100%" stopColor={edge} />
            </linearGradient>
            {/* One band of white light, feathered at both edges. Two
                stops of the same hue — never a spectrum. */}
            <linearGradient id={sweepId} x1="0" y1="0" x2="1" y2="0">
              <stop offset="0%" stopColor="#FFFFFF" stopOpacity="0" />
              <stop offset="50%" stopColor="#FFFFFF" stopOpacity="0.5" />
              <stop offset="100%" stopColor="#FFFFFF" stopOpacity="0" />
            </linearGradient>
            <clipPath id={clipId}>
              <path d={HEX} />
            </clipPath>
          </defs>

          <path d={HEX} fill={`url(#${faceId})`} />
          <path
            d={HEX}
            stroke="#FFFFFF"
            strokeOpacity="0.28"
            strokeWidth="1.6"
            strokeLinejoin="round"
          />
          <path d={STAR} fill="#FFFFFF" fillOpacity="0.92" />

          {!still && (
            <g clipPath={`url(#${clipId})`}>
              {/* The g carries the animated translate; the rect keeps
                  its own skew, so the band stays raked as it travels. */}
              <motion.g
                initial={{ x: -92 }}
                animate={{ x: 104 }}
                transition={{
                  delay: cfg.sweepDelay,
                  duration: cfg.sweepDuration,
                  ease: [0.4, 0, 0.2, 1],
                }}
                onAnimationComplete={onComplete}
              >
                <rect
                  x="0"
                  y="-24"
                  width="38"
                  height="144"
                  fill={`url(#${sweepId})`}
                  transform="skewX(-16)"
                />
              </motion.g>
            </g>
          )}
        </svg>
      </motion.div>

      {/* Text only ever fades and translates — a badge name that scales
          in stops looking like a name and starts looking like a prize. */}
      <motion.div
        initial={still ? { opacity: 0 } : { opacity: 0, y: 8 }}
        animate={still ? { opacity: 1 } : { opacity: 1, y: 0 }}
        transition={{
          delay: still ? 0.05 : cfg.labelDelay,
          duration: 0.34,
          ease: [0.22, 1, 0.36, 1],
        }}
        style={{ display: "flex", flexDirection: "column", gap: 3 }}
      >
        <span
          style={{
            fontSize: 10.5,
            fontWeight: 600,
            letterSpacing: "0.09em",
            textTransform: "uppercase",
            opacity: 0.5,
          }}
        >
          {eyebrow}
        </span>
        <span style={{ fontSize: 17, fontWeight: 640, letterSpacing: "-0.01em" }}>
          {title}
        </span>
        <span style={{ fontSize: 12.5, color: tone(58) }}>{note}</span>
      </motion.div>
    </div>
  );
}

About this pattern

The moment a criterion is finally met. The badge drops the last few percent into place on an over-damped spring, and once it has landed a single raked band of light travels across the metal and leaves. That one pass is the entire celebration: it says the object is precious without asking the viewer to sit through a firework. The restraint is functional, not just taste — badges are shown again every time the profile is opened, so anything that plays twice becomes something users learn to look away from. The name and criterion only fade and translate; a title that scales in reads as a prize card rather than a label.

Badge earnedProfile achievementCourse completionMembership tier granted

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

    Badge lands with its name and criterion arriving a beat behind it.

Related patterns