Signup Progress Meter
A slim meter advances as each account requirement is satisfied, and every row marks itself as it passes.
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 { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Signup Progress Meter
*
* A slim meter advances as each account requirement is satisfied, and
* every satisfied row marks itself as the meter passes it.
*
* Self-contained: depends only on `react` and `motion`. Works with zero
* props; tune via `variant`, `requirements`, `stepMs`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type SignupProgressMeterProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Card heading. */
title?: string;
/** Rows, in the order they get satisfied. */
requirements?: string[];
/** Beat between one requirement being satisfied and the next, in ms. */
stepMs?: number;
/** Meter and mark color. */
accent?: string;
/** Fires when the last requirement is satisfied. */
onComplete?: () => void;
};
type VariantConfig = {
/** How the fill travels to its new width. */
fill: { type: "spring"; stiffness: number; damping: number };
/** Seconds a row's mark takes to stroke itself in. */
mark: number;
/** How far a satisfied row shifts toward the reader, in px. */
nudge: number;
rowSpring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: a meter is telling the reader something factual, so it
// arrives at its value and stops — every spring here sits at or near a
// ratio of 1.0, well past the 0.8 floor, because a bar that overshoots
// 60% has briefly reported 68%. The percentage is text and only
// crossfades; it never scales.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Quick and matter-of-fact. For setup checklists shown on every visit
// until they are done.
subtle: {
fill: { type: "spring", stiffness: 450, damping: 43 },
mark: 0.16,
nudge: 0,
rowSpring: { type: "spring", stiffness: 580, damping: 47 },
},
// The bar visibly travels and each row acknowledges itself. The
// all-purpose setting.
default: {
fill: { type: "spring", stiffness: 300, damping: 34 },
mark: 0.26,
nudge: 2,
rowSpring: { type: "spring", stiffness: 460, damping: 40 },
},
// A longer travel with a slower mark, for a first-run setup that is
// meant to feel like progress rather than admin.
playful: {
fill: { type: "spring", stiffness: 210, damping: 29 },
mark: 0.36,
nudge: 3,
rowSpring: { type: "spring", stiffness: 390, damping: 36 },
},
};
/** 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, track or border correctly toned
* in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const DEFAULT_REQUIREMENTS = [
"Email confirmed",
"Password set",
"Display name added",
"Workspace named",
];
export default function SignupProgressMeter({
variant = "default",
title = "Finish setting up",
requirements = DEFAULT_REQUIREMENTS,
stepMs = 620,
accent = "#5B5BD6",
onComplete,
}: SignupProgressMeterProps) {
const [satisfied, setSatisfied] = useState(0);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const total = requirements.length;
const percent = total === 0 ? 0 : Math.round((satisfied / total) * 100);
// The requirements satisfy themselves on a beat so the pattern shows
// its own arc. Drive `setSatisfied` from your real account state.
useEffect(() => {
if (satisfied >= total) return;
const timer = setTimeout(
() => setSatisfied((current) => current + 1),
satisfied === 0 ? stepMs * 0.7 : stepMs
);
return () => clearTimeout(timer);
}, [satisfied, total, stepMs]);
useEffect(() => {
if (total > 0 && satisfied >= total) onComplete?.();
}, [satisfied, total, onComplete]);
return (
<div
style={{
width: 288,
display: "flex",
flexDirection: "column",
gap: 12,
padding: 18,
borderRadius: 16,
border: `1px solid ${tone(12)}`,
background: tone(6),
color: "inherit",
}}
>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 12,
}}
>
<span style={{ fontSize: 14.5, fontWeight: 650 }}>{title}</span>
{/* The readout sits in a fixed slot with tabular figures, so the
digits change without the heading beside them shifting. It
crossfades — a number that scales while it counts stops
reading as data. */}
<span
style={{
position: "relative",
width: 40,
height: 16,
fontSize: 12,
fontWeight: 600,
fontVariantNumeric: "tabular-nums",
textAlign: "right",
opacity: 0.65,
}}
>
<AnimatePresence initial={false}>
<motion.span
key={percent}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.16, ease: "easeOut" }}
style={{ position: "absolute", inset: 0 }}
>
{percent}%
</motion.span>
</AnimatePresence>
</span>
</div>
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={total}
aria-valuenow={satisfied}
aria-label={`${satisfied} of ${total} steps done`}
style={{
height: 5,
borderRadius: 3,
background: tone(11),
overflow: "hidden",
}}
>
{/* scaleX from the left edge rather than an animated width: the
fill stays on the compositor and the track never reflows. */}
<motion.div
initial={{ scaleX: 0 }}
animate={{ scaleX: total === 0 ? 0 : satisfied / total }}
transition={
reduceMotion ? { duration: 0.12, ease: "easeOut" } : cfg.fill
}
style={{
height: "100%",
borderRadius: 3,
background: accent,
transformOrigin: "left",
}}
/>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
{requirements.map((requirement, index) => {
const done = index < satisfied;
return (
<motion.div
key={requirement}
animate={{ x: done && !reduceMotion ? cfg.nudge : 0 }}
transition={reduceMotion ? { duration: 0 } : cfg.rowSpring}
style={{
display: "flex",
alignItems: "center",
gap: 9,
padding: "6px 0",
fontSize: 12.5,
}}
>
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
width: 18,
height: 18,
flex: "0 0 auto",
borderRadius: "50%",
border: `1.5px solid ${done ? accent : tone(20)}`,
color: accent,
// The ring recolors as a state change rather than an
// animated property, keeping the loop transform-only.
transition: "border-color 200ms ease-out",
}}
>
<svg width="11" height="11" viewBox="0 0 16 16" fill="none">
<motion.path
d="M3.6 8.4 6.6 11.3 12.4 5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
initial={false}
animate={{ pathLength: done ? 1 : 0, opacity: done ? 1 : 0 }}
transition={{
duration: reduceMotion ? 0.1 : cfg.mark,
ease: "easeOut",
}}
/>
</svg>
</span>
<span
style={{
opacity: done ? 0.95 : 0.5,
fontWeight: done ? 600 : 400,
transition: "opacity 220ms ease-out",
}}
>
{requirement}
</span>
</motion.div>
);
})}
</div>
<button
type="button"
disabled={satisfied < total}
style={{
width: "100%",
padding: "9px 14px",
fontSize: 13,
fontWeight: 600,
fontFamily: "inherit",
color: satisfied < total ? "inherit" : "#ffffff",
background: satisfied < total ? tone(8) : accent,
border: satisfied < total ? `1px solid ${tone(14)}` : "none",
borderRadius: 9,
opacity: satisfied < total ? 0.5 : 1,
cursor: satisfied < total ? "default" : "pointer",
transition: "background-color 220ms ease-out, opacity 220ms ease-out",
}}
>
{satisfied < total ? "Keep going" : "Enter your workspace"}
</button>
</div>
);
}About this pattern
The account-setup checklist, paced so finishing it feels like momentum rather than admin. The fill scales from its left edge instead of animating width, which keeps it on the compositor and stops the track reflowing, and the spring is damped to about a ratio of one: a bar that overshoots sixty percent has briefly reported sixty-eight, and this bar is making a factual claim. Each requirement acknowledges itself as the meter reaches it — the ring recolors, the mark strokes in, the row shifts a couple of pixels toward the reader — while the percentage crossfades in a fixed slot with tabular figures so no digit change moves the heading beside it.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Setup checklist
A requirements list with a single bar reporting how close the account is to live.
Related patterns
- Invite Code AcceptA valid invite code turns the field into the workspace tile it lets you into.
- Setup Checklist CompleteA finished task fills its box, draws its mark, sweeps a rule through the label, and moves the counter up by one.
- Team Seat JoinA teammate joins the avatar stack while the seat count rolls and the usage bar grows.