Profile Completion Ring
The ring around the avatar advances one arc per field, and each row marks itself as the arc reaches it.
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 · Profile Completion Ring
*
* A ring around the avatar advances one arc per field as the profile
* fills in, each row marking itself as the arc reaches it. On the last
* field the ring changes colour instead of celebrating.
*
* Self-contained: depends only on `react` and `motion`. Works with zero
* props; tune via `variant`, `fields`, `initials`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ProfileCompletionRingProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Field names, in the order they complete. */
fields?: string[];
/** Monogram inside the ring. */
initials?: string;
/** Ring color while the profile is unfinished. */
accent?: string;
/** Ring color once every field is in. */
completeColor?: string;
/** Fires each time a field completes, with the new count. */
onAdvance?: (filled: number) => void;
};
type VariantConfig = {
/** Pause before the first arc, in seconds. */
startDelay: number;
/** Time between fields completing, in seconds. */
stepSeconds: number;
arc: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: the arc may spring, the readout may not. Every spring
// sits at or above a 0.8 damping ratio, so the ring never rocks past its
// value and back — a progress indicator that overshoots is lying about
// the number underneath it, and the percentage is plain text that never
// scales.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Brisk and nearly linear. For a ring in a sidebar.
subtle: {
startDelay: 0.25,
stepSeconds: 0.45,
arc: { type: "spring", stiffness: 320, damping: 34 },
},
// One field per beat. The all-purpose setting.
default: {
startDelay: 0.35,
stepSeconds: 0.62,
arc: { type: "spring", stiffness: 240, damping: 30 },
},
// Slower arcs with more air between them, for a settings page hero.
playful: {
startDelay: 0.45,
stepSeconds: 0.8,
arc: { type: "spring", stiffness: 180, damping: 24 },
},
};
/** Neutral surfaces are mixed from the inherited text color, so the card
* reads correctly on a light page and on a dark one. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const SAMPLE_FIELDS = ["Profile photo", "Display name", "Role", "Time zone"];
const RING_SIZE = 76;
const RING_CENTER = RING_SIZE / 2;
const RING_RADIUS = 33;
const RING_STROKE = 4;
export default function ProfileCompletionRing({
variant = "default",
fields = SAMPLE_FIELDS,
initials = "AR",
accent = "#5B5BD6",
completeColor = "#3E9B6B",
onAdvance,
}: ProfileCompletionRingProps) {
const [stepped, setStepped] = useState(0);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const total = fields.length;
// Reduced motion: the profile is shown as it stands, all at once. The
// number and the marks carry the same information the arcs did, so
// this is derived rather than stepped — no timers, no cascade.
const filled = reduceMotion ? total : stepped;
const complete = filled >= total;
const percent = Math.round((filled / total) * 100);
useEffect(() => {
if (reduceMotion || stepped >= total) return;
const wait = stepped === 0 ? cfg.startDelay : cfg.stepSeconds;
const timer = setTimeout(() => {
setStepped(stepped + 1);
onAdvance?.(stepped + 1);
}, wait * 1000);
return () => clearTimeout(timer);
}, [reduceMotion, stepped, total, cfg.startDelay, cfg.stepSeconds, onAdvance]);
return (
<div
style={{
width: 320,
padding: 18,
borderRadius: 18,
border: `1px solid ${tone(12)}`,
background: tone(6),
boxSizing: "border-box",
}}
>
<div style={{ fontSize: 15, fontWeight: 650 }}>Complete your profile</div>
<p style={{ margin: "4px 0 14px", fontSize: 12, opacity: 0.55 }}>
Teammates recognise a filled-in profile faster than an initial.
</p>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<div style={{ flex: "none", width: RING_SIZE }}>
<div style={{ position: "relative", height: RING_SIZE }}>
<svg
width={RING_SIZE}
height={RING_SIZE}
viewBox={`0 0 ${RING_SIZE} ${RING_SIZE}`}
fill="none"
role="img"
aria-label={`Profile ${percent} percent complete`}
style={{ display: "block" }}
>
<circle
cx={RING_CENTER}
cy={RING_CENTER}
r={RING_RADIUS}
stroke={tone(13)}
strokeWidth={RING_STROKE}
/>
{/* Dash offset starts at 3 o'clock, so the group is rotated
to put the first field at the top where it is read. */}
<g transform={`rotate(-90 ${RING_CENTER} ${RING_CENTER})`}>
<motion.circle
cx={RING_CENTER}
cy={RING_CENTER}
r={RING_RADIUS}
strokeWidth={RING_STROKE}
strokeLinecap="round"
initial={{ pathLength: 0, stroke: accent }}
animate={{
pathLength: filled / total,
stroke: complete ? completeColor : accent,
}}
transition={
reduceMotion
? { duration: 0 }
: {
...cfg.arc,
stroke: { duration: 0.4, ease: "easeOut" as const },
}
}
/>
</g>
</svg>
<span
aria-hidden
style={{
position: "absolute",
inset: RING_STROKE + 4,
display: "grid",
placeItems: "center",
borderRadius: 999,
background: tone(10),
fontSize: 17,
fontWeight: 650,
letterSpacing: 0.4,
opacity: 0.7,
}}
>
{initials}
</span>
</div>
<div
style={{
marginTop: 8,
textAlign: "center",
fontSize: 11.5,
fontWeight: 600,
// Plain text, swapped not animated: digits that scale or
// roll pull the eye off the arc that is actually moving.
fontVariantNumeric: "tabular-nums",
opacity: 0.6,
}}
>
{complete ? "All set" : `${percent}% done`}
</div>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{fields.map((field, index) => {
const done = index < filled;
return (
<div
key={field}
style={{
display: "flex",
alignItems: "center",
gap: 9,
height: 26,
fontSize: 12.5,
}}
>
<span
style={{
display: "grid",
placeItems: "center",
flex: "none",
width: 16,
height: 16,
borderRadius: 999,
border: `1.5px solid ${done ? "transparent" : tone(18)}`,
background: done ? tone(8) : "transparent",
}}
>
<svg width="11" height="11" viewBox="0 0 16 16" fill="none">
<motion.path
d="M3.6 8.4 6.6 11.4 12.4 5.2"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: 0, stroke: accent }}
animate={{
pathLength: done ? 1 : 0,
stroke: complete ? completeColor : accent,
}}
transition={
reduceMotion
? { duration: 0 }
: {
duration: 0.28,
ease: "easeOut" as const,
stroke: { duration: 0.4, ease: "easeOut" as const },
}
}
/>
</svg>
</span>
<motion.span
animate={{ opacity: done ? 0.85 : 0.42 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
{field}
</motion.span>
</div>
);
})}
</div>
</div>
</div>
);
}About this pattern
A completeness indicator that shows its working. The arc around the avatar extends by one quarter as each field lands, and the matching row in the list draws its own mark at the same moment, so the ring and the reasons for it are never out of step. The arc springs but never overshoots — an indicator that rocks past its value and back is lying about the number printed underneath it — and that number is plain tabular text that swaps rather than counts, because scaling digits beside a moving arc splits the eye in two. Finishing changes the ring's colour and the label, and that is the whole reward; there is nothing else to celebrate about a filled-in form.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Profile page
A ring beside the avatar reports how much of the profile is filled in.
Related patterns
- Import Data ConnectA line draws itself between two service marks, records run along it, and the destination takes a check when the import lands.
- Setup CompleteThe last item checks itself off, the checklist clears, and a single confirmation takes its place.
- Value Prop CarouselIntro slides advance on their own, with the artwork crossfading a beat behind the copy.