Verify Email Check
The pending ring strokes its way round while the link is checked, and the frame it closes on is the confirmed 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 { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Verify Email Check
*
* The pending ring strokes its way round while the link is checked, and
* the moment it closes it becomes the confirmed badge.
*
* Self-contained: depends only on `react` and `motion`. Works with zero
* props; tune via `variant`, `address`, `accent`, `checkMs`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type VerifyEmailCheckProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Address echoed back under the heading. */
address?: string;
/** How long the ring takes to close, in ms. */
checkMs?: number;
/** Confirmed color for the ring, mark and button. */
accent?: string;
/** Fires once the confirmed state is on screen. */
onConfirmed?: () => void;
};
type VariantConfig = {
/** Seconds the mark takes to stroke in once the ring has closed. */
mark: number;
/** How far under full size the tinted disc starts. */
discFrom: number;
discSpring: { type: "spring"; stiffness: number; damping: number };
/** Travel of the arriving button, in px. */
rise: number;
};
// Quality rule: the ring closes exactly once and the badge it becomes
// never pulses, re-scales or replays — a confirmation that celebrates
// twice reads as unsure the first time. The disc behind is the only
// spring and it carries no text; it sits above a 0.8 damping ratio, so
// it lands at full size and stops.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Understated. For a link people click and immediately move past.
subtle: {
mark: 0.2,
discFrom: 0.94,
discSpring: { type: "spring", stiffness: 560, damping: 46 },
rise: 5,
},
// The moment of resolution is legible. The all-purpose setting.
default: {
mark: 0.28,
discFrom: 0.88,
discSpring: { type: "spring", stiffness: 460, damping: 40 },
rise: 9,
},
// A slower mark and a wider disc, for a first confirmation worth a
// small beat of pleasure.
playful: {
mark: 0.36,
discFrom: 0.8,
discSpring: { type: "spring", stiffness: 400, damping: 34 },
rise: 14,
},
};
/** Theme-adaptive neutral: `currentColor` is the inherited text color —
* near-black on a light page, near-white on a dark one — so mixing it
* with `transparent` yields a surface, ring or border correctly toned
* in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function VerifyEmailCheck({
variant = "default",
address = "you@company.com",
checkMs = 1000,
accent = "#2E9E6B",
onConfirmed,
}: VerifyEmailCheckProps) {
const [confirmed, setConfirmed] = useState(false);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// The link is being checked on arrival, so this runs itself. Resolve
// it from your own request instead of the timer.
useEffect(() => {
const timer = setTimeout(
() => setConfirmed(true),
reduceMotion ? 400 : checkMs
);
return () => clearTimeout(timer);
}, [checkMs, reduceMotion]);
useEffect(() => {
if (confirmed) onConfirmed?.();
}, [confirmed, onConfirmed]);
const ringSeconds = reduceMotion ? 0 : checkMs / 1000;
return (
<div
style={{
width: 288,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 13,
padding: "26px 22px 20px",
borderRadius: 16,
border: `1px solid ${tone(12)}`,
background: tone(6),
color: "inherit",
textAlign: "center",
}}
>
<div
style={{
position: "relative",
display: "grid",
placeItems: "center",
width: 76,
height: 76,
}}
>
{/* The tinted disc arrives underneath at the instant the ring
closes, which is what turns a progress outline into a badge
without swapping one element for another. */}
<motion.span
aria-hidden
initial={false}
animate={{
opacity: confirmed ? 1 : 0,
scale: confirmed || reduceMotion ? 1 : cfg.discFrom,
}}
transition={
reduceMotion
? { duration: 0.18, ease: "easeOut" }
: { ...cfg.discSpring, opacity: { duration: 0.2 } }
}
style={{
position: "absolute",
width: 62,
height: 62,
borderRadius: "50%",
background: `color-mix(in srgb, ${accent} 14%, transparent)`,
}}
/>
<svg
width="72"
height="72"
viewBox="0 0 72 72"
fill="none"
role="img"
aria-label={confirmed ? "Email confirmed" : "Confirming your email"}
>
<circle cx="36" cy="36" r="29" stroke={tone(10)} strokeWidth="3" />
{/* One ring, two meanings: it strokes its way round while the
link is checked, and the frame it finishes on is the badge
outline. Nothing is swapped, so there is no seam between
waiting and being done. The quarter-turn lives on a plain
<g> so the animated element owns no transform of its own. */}
<g transform="rotate(-90 36 36)">
<motion.circle
cx="36"
cy="36"
r="29"
stroke={confirmed ? accent : tone(38)}
strokeWidth="3"
strokeLinecap="round"
initial={{ pathLength: reduceMotion ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: ringSeconds, ease: "easeInOut" }}
style={{ transition: "stroke 240ms ease-out" }}
/>
</g>
<motion.path
d="M24.5 36.8 32.6 44.6 47.5 28.8"
stroke={accent}
strokeWidth="3.4"
strokeLinecap="round"
strokeLinejoin="round"
initial={false}
animate={{
pathLength: confirmed ? 1 : 0,
opacity: confirmed ? 1 : 0,
}}
transition={{
duration: reduceMotion ? 0.14 : cfg.mark,
delay: reduceMotion ? 0 : 0.06,
ease: "easeOut",
}}
/>
</svg>
</div>
{/* Both headings live in one fixed slot so the card never resizes
under the swap, and both are set at the same size — this text
only ever crossfades, it is never scaled. */}
<div style={{ position: "relative", width: "100%", height: 22 }}>
{[
{ key: "pending", text: "Confirming your email", shown: !confirmed },
{ key: "done", text: "Email confirmed", shown: confirmed },
].map((line) => (
<motion.div
key={line.key}
initial={false}
animate={{ opacity: line.shown ? 1 : 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
fontSize: 15.5,
fontWeight: 650,
}}
>
{line.text}
</motion.div>
))}
</div>
<p
role="status"
aria-live="polite"
style={{
margin: 0,
minHeight: 34,
fontSize: 12.5,
lineHeight: 1.55,
opacity: 0.62,
}}
>
{confirmed ? (
<>
<span style={{ fontWeight: 600, opacity: 0.9 }}>{address}</span> is
now the address on your account.
</>
) : (
<>Checking the link you opened. This takes a second.</>
)}
</p>
<motion.button
type="button"
initial={false}
animate={{
opacity: confirmed ? 1 : 0,
y: confirmed || reduceMotion ? 0 : cfg.rise,
}}
transition={{
duration: 0.24,
delay: confirmed && !reduceMotion ? 0.1 : 0,
ease: "easeOut",
}}
style={{
width: "100%",
padding: "10px 14px",
fontSize: 13,
fontWeight: 600,
fontFamily: "inherit",
color: "#ffffff",
background: accent,
border: "none",
borderRadius: 9,
pointerEvents: confirmed ? "auto" : "none",
cursor: "pointer",
}}
>
Continue to Meridian
</motion.button>
</div>
);
}About this pattern
The page an emailed activation link lands on has two states and about a second between them, which is exactly long enough to do badly. Here the waiting indicator and the confirmation are the same ring: it strokes its way round while the link is checked, and the frame it finishes on is the badge outline, recolored, with a tinted disc arriving underneath. Nothing is swapped out, so there is no seam where a loader disappears and a result pops in — and no second flourish, because a confirmation that celebrates twice reads as unsure the first time. Both headings sit in one fixed slot at the same size, so the card never resizes and the type only ever crossfades.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Sign-in screen
A single card that checks the link and reports the outcome in place.
Related patterns
- Password Reset SentThe request panel gives way to a confirmation, with the envelope settling in and its flap stroking closed.
- Biometric PromptA fingerprint glyph strokes itself in while the device reads, then a ring settles once it accepts.
- Sign-in Form EntranceHeading, fields and button rise into place in one quick sequence as the screen opens.