All patterns

First Time Badge

The first time an account does something, a small rosette settles beside the row and a marker line follows.

achievementfriendlypremiumautomatic · finite · starter · ~0.8s
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 { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · First Time Badge
 *
 * The first time someone does a thing, and the small rosette that shows
 * up beside it to say so. The row itself is already on screen — it is
 * the event — so nothing about it moves; only the seal arrives, settles
 * once, and is then simply part of the row.
 *
 * The restraint is the point. A first-time marker fires on a person's
 * best day with the product and never again for that action, so it has
 * to feel like the product noticed rather than like the product cheered.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color so the row reads on a
 * light page and a dark one; the seal colour is semantic and literal.
 * Works with zero props; tune via `variant`, `title`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type FirstTimeBadgeProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** What the person just did. */
  title?: string;
  /** Timestamp or detail under the title. */
  detail?: string;
  /** Wording of the marker under the title. */
  markerLabel?: string;
  /** Seal diameter in px. */
  size?: number;
  /** Seal colour. Semantic, so it stays literal. */
  accent?: string;
  /** Fires once the marker line has arrived. */
  onEarned?: () => void;
};

type VariantConfig = {
  /** Beat before the seal arrives, so the row is read first. */
  delay: number;
  /** How far under full size the seal starts. */
  from: number;
  /** px the seal drops through on its way in. */
  drop: number;
  spring: { type: "spring"; stiffness: number; damping: number };
  /** Extra beat before the marker line follows. */
  markerGap: number;
};

// Damping ratios here run 0.83–0.96, so the seal lands once and holds.
// Variants change the beat and the distance travelled — never how many
// times anything rebounds.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Almost no travel. For a feed where several firsts can land in a day.
  subtle: {
    delay: 0.14,
    from: 0.86,
    drop: 3,
    spring: { type: "spring", stiffness: 520, damping: 44 },
    markerGap: 0.1,
  },
  // The all-purpose setting: a short drop, then the line underneath.
  default: {
    delay: 0.24,
    from: 0.72,
    drop: 5,
    spring: { type: "spring", stiffness: 400, damping: 34 },
    markerGap: 0.14,
  },
  // Longer travel for a summary screen where the first is the headline.
  playful: {
    delay: 0.32,
    from: 0.62,
    drop: 8,
    spring: { type: "spring", stiffness: 320, damping: 30 },
    markerGap: 0.18,
  },
};

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

/** Petal count of the rosette. Geometry, generated rather than drawn. */
const PETALS = 12;

export default function FirstTimeBadge({
  variant = "default",
  title = "Published your first report",
  detail = "Q3 Performance Summary",
  markerLabel = "First time",
  size = 38,
  accent = "#A8562F",
  onEarned,
}: FirstTimeBadgeProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const still = !!reduceMotion;

  const engraved = `color-mix(in srgb, ${accent} 58%, #1A0E06)`;

  return (
    <div
      style={{
        display: "flex",
        alignItems: "flex-start",
        gap: 12,
        width: 300,
        boxSizing: "border-box",
        padding: "13px 14px",
        borderRadius: 14,
        border: `1px solid ${tone(12)}`,
        background: tone(5),
      }}
    >
      <span
        aria-hidden
        style={{
          flex: "none",
          display: "grid",
          placeItems: "center",
          width: 30,
          height: 30,
          borderRadius: 9,
          background: tone(9),
          color: tone(60),
        }}
      >
        <svg
          width="15"
          height="15"
          viewBox="0 0 18 18"
          fill="none"
          stroke="currentColor"
          strokeWidth="1.5"
          strokeLinecap="round"
          strokeLinejoin="round"
        >
          <path d="M4.2 2.6h6.6L13.8 5.6v9.8H4.2z" />
          <path d="M6.6 8.6h4.8M6.6 11.4h3.2" />
        </svg>
      </span>

      <span style={{ minWidth: 0, flex: 1 }}>
        <span
          style={{
            display: "block",
            fontSize: 12.5,
            fontWeight: 620,
            letterSpacing: "-0.005em",
            lineHeight: 1.35,
          }}
        >
          {title}
        </span>
        <span
          style={{ display: "block", marginTop: 2, fontSize: 11, color: tone(46) }}
        >
          {detail}
        </span>

        {/* Fixed slot: the marker fades into space that was always
            reserved, so the row never reflows around it. */}
        <span style={{ display: "block", height: 20, marginTop: 6 }}>
          <motion.span
            initial={still ? { opacity: 0 } : { opacity: 0, y: 5 }}
            animate={still ? { opacity: 1 } : { opacity: 1, y: 0 }}
            transition={{
              delay: still ? 0.04 : cfg.delay + cfg.markerGap,
              duration: still ? 0.18 : 0.3,
              ease: [0.22, 1, 0.36, 1],
            }}
            onAnimationComplete={onEarned}
            style={{
              display: "inline-flex",
              alignItems: "center",
              gap: 5,
              padding: "3px 8px 3px 6px",
              borderRadius: 999,
              fontSize: 10.5,
              fontWeight: 640,
              letterSpacing: "0.01em",
              color: accent,
              background: `color-mix(in srgb, ${accent} 13%, transparent)`,
            }}
          >
            <svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden>
              <path
                d="M6 1.4 7.4 4.5 10.6 4.9 8.2 7.1 8.9 10.4 6 8.8 3.1 10.4 3.8 7.1 1.4 4.9 4.6 4.5Z"
                fill="currentColor"
              />
            </svg>
            {markerLabel}
          </motion.span>
        </span>
      </span>

      {/* The seal is an object, not a label, so it may scale on the way
          in. It arrives once and then holds absolutely still. */}
      <motion.span
        role="img"
        aria-label={markerLabel}
        initial={
          still ? { opacity: 0 } : { opacity: 0, scale: cfg.from, y: -cfg.drop }
        }
        animate={still ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}
        transition={
          still
            ? { duration: 0.2, ease: "easeOut" }
            : {
                ...cfg.spring,
                delay: cfg.delay,
                opacity: { delay: cfg.delay, duration: 0.16, ease: "easeOut" },
              }
        }
        style={{ flex: "none", width: size, height: size, lineHeight: 0 }}
      >
        <svg viewBox="0 0 44 44" width="100%" height="100%" fill="none">
          {/* Rosette petals, generated so the shape stays crisp at any
              size and needs no asset. */}
          {Array.from({ length: PETALS }, (_, index) => (
            <rect
              key={index}
              x="20.5"
              y="1.5"
              width="3"
              height="7"
              rx="1.5"
              fill={accent}
              opacity="0.85"
              transform={`rotate(${(index * 360) / PETALS} 22 22)`}
            />
          ))}
          <circle cx="22" cy="22" r="16" fill={accent} />
          <circle
            cx="22"
            cy="22"
            r="12.6"
            stroke="#FFFFFF"
            strokeOpacity="0.34"
            strokeWidth="1.2"
          />
          {/* The engraved figure is geometry cut into the seal face —
              part of the object, drawn rather than typeset. */}
          <path
            d="M18.9 18.2 22.4 15.2 22.4 28.1"
            stroke="#FFFFFF"
            strokeWidth="2.5"
            strokeLinecap="round"
            strokeLinejoin="round"
          />
          <path
            d="M19.4 28.1H25.9"
            stroke="#FFFFFF"
            strokeWidth="2.5"
            strokeLinecap="round"
          />
          <path
            d="M22 6.4a15.6 15.6 0 0 1 15.6 15.6"
            stroke={engraved}
            strokeOpacity="0.35"
            strokeWidth="1.6"
            strokeLinecap="round"
          />
        </svg>
      </motion.span>
    </div>
  );
}

About this pattern

The debut of an action, marked once and never again. The activity row is already on screen — it is the event — so nothing about it moves; only the seal arrives, drops a few pixels onto its resting place, and settles a single time. A beat later a small marker line appears in a slot that was always reserved for it, so the row never reflows. Two decisions keep this usable in a real feed. The seal is a drawn object rather than a label, which is why it is allowed to scale on entry while the wording beside it only ever fades and translates. And the whole sequence is under half a second of travel: a debut marker fires on somebody's best day with the product, so it should read as the product noticing, not as the product cheering.

First project publishedFirst invoice sentDebut post in a communityNever-used feature tried

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

    A debut marker dropping beside a freshly completed row in the activity list.

Related patterns