Challenge Complete
A seal presses onto the finished challenge card and settles a couple of degrees off square.
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 { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Challenge Complete Stamp
*
* A seal pressed onto a finished challenge card. It arrives slightly
* oversized and off-angle, then compresses onto the paper and settles a
* couple of degrees back — the way something actually stamped lands.
*
* The wordmark is layered over the seal rather than nested inside it,
* so the rings can carry the press while the lettering only fades. Text
* that scales during a press reads as a sticker being enlarged, which
* is the opposite of the impression the gesture is meant to leave.
*
* Self-contained: depends only on `motion` (react ships with your app).
* The card is mixed from the inherited text color; the ink colour is
* semantic and stays literal.
* Works with zero props; tune via `variant`, `title`, `ink`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ChallengeCompleteStampProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Challenge name. */
title?: string;
/** Line under the challenge name. */
detail?: string;
/** Word on the seal. */
stampWord?: string;
/** Small line under the seal's word. */
stampDate?: string;
/** Ink colour. Semantic, so it stays literal. */
ink?: string;
/** Fires once the seal has settled. */
onStamped?: () => void;
};
type VariantConfig = {
/** Beat before the press, so the card is read unstamped. */
delay: number;
/** How oversized the seal is on approach. */
scaleFrom: number;
/** Angle it comes in at, in degrees. */
rotateFrom: number;
/** Angle it settles to. */
rotateTo: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// Damped at 0.82 and above: one compression, no rebound. A seal that
// bounces off the page never touched it.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
subtle: {
delay: 0.24,
scaleFrom: 1.08,
rotateFrom: -7,
rotateTo: -5,
spring: { type: "spring", stiffness: 560, damping: 42 },
},
default: {
delay: 0.36,
scaleFrom: 1.2,
rotateFrom: -13,
rotateTo: -8,
spring: { type: "spring", stiffness: 420, damping: 34 },
},
playful: {
delay: 0.46,
scaleFrom: 1.34,
rotateFrom: -19,
rotateTo: -10,
spring: { type: "spring", stiffness: 340, damping: 30 },
},
};
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function ChallengeCompleteStamp({
variant = "default",
title = "30-day writing challenge",
detail = "30 of 30 entries · finished 4 days early",
stampWord = "Completed",
stampDate = "18 Aug",
ink = "#2E7D62",
onStamped,
}: ChallengeCompleteStampProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const still = !!reduceMotion;
// Reduced motion: the card is simply already stamped. 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, stamped: still });
if (run.key !== runKey) setRun({ key: runKey, stamped: still });
const stamped = run.key === runKey ? run.stamped : still;
useEffect(() => {
if (still) return;
const timer = setTimeout(
() => setRun({ key: runKey, stamped: true }),
cfg.delay * 1000
);
return () => clearTimeout(timer);
}, [still, cfg.delay, runKey]);
return (
<div
style={{
position: "relative",
width: 300,
padding: "18px 18px 20px",
borderRadius: 16,
background: tone(5),
border: `1px solid ${tone(11)}`,
overflow: "hidden",
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: 4, maxWidth: 178 }}>
<span
style={{
fontSize: 10.5,
fontWeight: 620,
letterSpacing: "0.09em",
textTransform: "uppercase",
color: tone(44),
}}
>
Challenge
</span>
<span style={{ fontSize: 15, fontWeight: 650, lineHeight: 1.3 }}>
{title}
</span>
<span style={{ fontSize: 11.5, color: tone(52), lineHeight: 1.4 }}>
{detail}
</span>
</div>
<div
style={{
display: "flex",
gap: 4,
marginTop: 16,
maxWidth: 170,
}}
>
{Array.from({ length: 10 }, (_, index) => (
<span
key={index}
style={{
flex: 1,
height: 5,
borderRadius: 999,
background: `color-mix(in srgb, ${ink} 38%, transparent)`,
}}
/>
))}
</div>
{/* Rotation lives on the outer wrapper, the press on the middle
one, and the lettering on a layer that only ever fades. Three
jobs, three elements — none of them fighting for `transform`. */}
<motion.div
aria-label={`${stampWord} ${stampDate}`}
role="img"
initial={{ rotate: still ? cfg.rotateTo : cfg.rotateFrom }}
animate={{ rotate: stamped ? cfg.rotateTo : cfg.rotateFrom }}
transition={still ? { duration: 0 } : cfg.spring}
style={{
position: "absolute",
right: 16,
bottom: 18,
width: 96,
height: 96,
}}
>
<motion.div
initial={{ scale: still ? 1 : cfg.scaleFrom, opacity: 0 }}
animate={{ scale: stamped ? 1 : cfg.scaleFrom, opacity: stamped ? 1 : 0 }}
transition={
still
? { duration: 0.2, ease: "easeOut" }
: { ...cfg.spring, opacity: { duration: 0.12, ease: "easeOut" } }
}
onAnimationComplete={onStamped}
style={{ width: "100%", height: "100%", lineHeight: 0 }}
>
<svg viewBox="0 0 96 96" width="100%" height="100%" fill="none" aria-hidden>
<circle cx="48" cy="48" r="45" stroke={ink} strokeWidth="2.4" opacity="0.85" />
<circle
cx="48"
cy="48"
r="39"
stroke={ink}
strokeWidth="1.1"
strokeDasharray="3 4"
opacity="0.6"
/>
{Array.from({ length: 24 }, (_, index) => {
const angle = (index / 24) * Math.PI * 2;
const inner = 33;
const outer = 36;
return (
<line
key={index}
x1={48 + Math.cos(angle) * inner}
y1={48 + Math.sin(angle) * inner}
x2={48 + Math.cos(angle) * outer}
y2={48 + Math.sin(angle) * outer}
stroke={ink}
strokeWidth="1.4"
strokeLinecap="round"
opacity="0.42"
/>
);
})}
</svg>
</motion.div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: stamped ? 1 : 0 }}
transition={{
duration: still ? 0.2 : 0.22,
delay: still ? 0 : 0.07,
ease: "easeOut",
}}
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 3,
color: ink,
pointerEvents: "none",
}}
>
<span
style={{
fontSize: 12,
fontWeight: 720,
letterSpacing: "0.11em",
textTransform: "uppercase",
}}
>
{stampWord}
</span>
<span
style={{
width: 34,
height: 1,
background: "currentColor",
opacity: 0.5,
}}
/>
<span style={{ fontSize: 9.5, fontWeight: 600, letterSpacing: "0.06em" }}>
{stampDate}
</span>
</motion.div>
</motion.div>
</div>
);
}About this pattern
A completion mark that behaves like something physically applied. The seal approaches slightly oversized and further off-angle than it will end up, compresses onto the card on an over-damped spring, and rotates back a few degrees as it lands. The construction is three nested elements with one job each: the outer holds the angle, the middle holds the press, and the wordmark sits on a layer that only fades. That separation exists because lettering caught inside a scaling parent reads as a sticker being enlarged, which is the exact opposite of an impression. The seal is drawn entirely in inline SVG — rings, dashes and tick marks generated from a loop — so nothing has to ship alongside the copied file.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Activity summary
A completion mark applied over a finished programme card.
Related patterns
- Badge UnlockAn earned badge lands and one band of light crosses its face — a single pass, then still.
- Goal Ring CloseAn activity ring runs out the last of its gap, the caps meet, and the total settles once.
- Leaderboard ClimbYour row travels up the board while the rows it passes slide down to make the space.