First Time Badge
The first time an account does something, a small rosette settles beside the row and a marker line follows.
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 { 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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Achievements
A debut marker dropping beside a freshly completed row in the activity list.
Related patterns
- Personal RecordThe newest column overtakes the best-ever marker, which rides up on it while the beaten mark drops away as a reference.
- Progress Bar CompleteThe last segment fills, then the completion colour runs left to right across the whole bar.
- Certificate IssueThe border strokes itself around the sheet, the seal settles on, and the name is written last.