Spinner to Check
The waiting arc finishes its turn, closes into a full ring, and the confirming stroke lands inside 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 {
AnimatePresence,
motion,
useAnimationControls,
useReducedMotion,
} from "motion/react";
/**
* Vibary · Spinner to Check
*
* The waiting arc and the confirmation live on the same circle. When
* the request lands the arc finishes the turn it was already in,
* closes into a complete ring, shifts to the confirm color, and the
* check strokes itself inside it. Nothing is replaced, so there is no
* frame where the badge is empty and no jump between two glyphs of
* different sizes.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Chrome is mixed from the inherited text color, so it reads correctly
* on a light page and on a dark one; the two state colors stay literal.
* Works with zero props; pass `pending` to drive it from your request.
* Requires the automatic JSX runtime (default since React 17).
*/
export type SpinnerToCheckProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Drive this from your request state. Left undefined, the component
* settles after `settleAfterMs` so the file runs as-is. */
pending?: boolean;
/** Only consulted while `pending` is undefined. */
settleAfterMs?: number;
/** Label held while the request is in flight. */
pendingLabel?: string;
/** Label held once it has landed. */
doneLabel?: string;
/** Badge diameter in px. */
size?: number;
/** Waiting color. A state color, so it stays literal. */
accent?: string;
/** Confirmed color. A state color, so it stays literal. */
confirmColor?: string;
/** Fires once the confirmation has finished. */
onSettled?: () => void;
};
type VariantConfig = {
/** Seconds per turn while waiting. */
spinSeconds: number;
/** Seconds for the final turn plus the ring closing. */
closeSeconds: number;
/** Seconds for the check to stroke in. */
strokeSeconds: number;
/** How much of the close has passed before the check starts, 0–1. */
handoff: number;
};
// Quality rule: nothing here scales, bounces or pops. This fires on
// every save, and by the twentieth one a celebration is noise — the
// badge holds its size and lets the stroke carry the whole state
// change. Variants differ in pace, never in exuberance.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// The quickest close. For toolbars and rows where several of these
// resolve at once.
subtle: {
spinSeconds: 1,
closeSeconds: 0.34,
strokeSeconds: 0.2,
handoff: 0.75,
},
// The all-purpose setting: a visible last turn, then the close.
default: {
spinSeconds: 0.85,
closeSeconds: 0.44,
strokeSeconds: 0.26,
handoff: 0.68,
},
// A longer close for a single prominent confirmation — a checkout, a
// publish, something the user did once and is watching.
playful: {
spinSeconds: 0.72,
closeSeconds: 0.54,
strokeSeconds: 0.32,
handoff: 0.62,
},
};
const ACCENT = "#4C7DF0";
const CONFIRM = "#34D399";
const CHECK_PATH = "M14.6 22.4 L19.9 27.7 L29.6 16.9";
/** Theme-adaptive neutral: mixing the inherited text color with
* transparent yields a track that is correctly toned in either theme.
* The waiting and confirmed colors stay literal — they are states. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function SpinnerToCheck({
variant = "default",
pending,
settleAfterMs = 1600,
pendingLabel = "Syncing changes",
doneLabel = "Changes saved",
size = 44,
accent = ACCENT,
confirmColor = CONFIRM,
onSettled,
}: SpinnerToCheckProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [selfSettled, setSelfSettled] = useState(false);
const spin = useAnimationControls();
// Uncontrolled by default so the file runs on its own; the moment a
// caller passes `pending`, this timer stays out of the way.
useEffect(() => {
if (pending !== undefined) return;
const timer = setTimeout(() => setSelfSettled(true), settleAfterMs);
return () => clearTimeout(timer);
}, [pending, settleAfterMs]);
const waiting = pending ?? !selfSettled;
// The turn is not cut off — it is completed. Starting a finite
// animation to 360 from wherever the loop happens to be lands the arc
// at the top, which is where a closed ring wants to begin.
useEffect(() => {
if (reduceMotion) {
spin.set({ rotate: 0 });
return;
}
if (waiting) {
spin.set({ rotate: 0 });
spin.start({
rotate: 360,
transition: { duration: cfg.spinSeconds, repeat: Infinity, ease: "linear" },
});
} else {
spin.start({
rotate: 360,
transition: { duration: cfg.closeSeconds, ease: [0.3, 0.7, 0.4, 1] },
});
}
}, [waiting, reduceMotion, spin, cfg.spinSeconds, cfg.closeSeconds]);
const ringColor = waiting ? accent : confirmColor;
const label = waiting ? pendingLabel : doneLabel;
return (
<div
aria-busy={waiting}
aria-live="polite"
style={{ display: "flex", alignItems: "center", gap: 12 }}
>
<div style={{ width: size, height: size, flexShrink: 0 }}>
<svg width={size} height={size} viewBox="0 0 44 44" fill="none">
<circle cx="22" cy="22" r="17.5" stroke={tone(11)} strokeWidth="3" />
<motion.g style={{ transformOrigin: "22px 22px" }} animate={spin}>
{/* One arc for both states. Waiting, it is a quarter of the
circle; landed, pathLength runs to 1 and the same stroke
becomes the closed ring around the check. */}
<motion.circle
cx="22"
cy="22"
r="17.5"
strokeWidth="3"
strokeLinecap="round"
transform="rotate(-90 22 22)"
initial={{ pathLength: 0.26 }}
animate={{
pathLength: waiting ? 0.26 : 1,
stroke: ringColor,
}}
transition={{
pathLength: reduceMotion
? { duration: 0.18, ease: "easeOut" }
: { duration: cfg.closeSeconds, ease: [0.3, 0.7, 0.4, 1] },
stroke: { duration: cfg.closeSeconds * 0.6, ease: "easeOut" },
}}
/>
</motion.g>
<motion.path
d={CHECK_PATH}
stroke={confirmColor}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: 0, opacity: 0 }}
animate={{
pathLength: waiting ? 0 : 1,
opacity: waiting ? 0 : 1,
}}
transition={{
pathLength: reduceMotion
? { duration: 0 }
: {
duration: cfg.strokeSeconds,
ease: "easeOut",
// The check starts before the ring has quite closed,
// so the two read as one gesture rather than two.
delay: waiting ? 0 : cfg.closeSeconds * cfg.handoff,
},
opacity: {
duration: reduceMotion ? 0.18 : 0.1,
delay: waiting || reduceMotion ? 0 : cfg.closeSeconds * cfg.handoff,
},
}}
onAnimationComplete={() => {
if (!waiting) onSettled?.();
}}
/>
</svg>
</div>
{/* The label is type: it cross-fades in place, at one size, on a
slot wide enough for either wording. */}
<div style={{ position: "relative", minWidth: 132, height: 18 }}>
<AnimatePresence initial={false} mode="wait">
<motion.span
key={label}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.16, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
fontSize: 13.5,
lineHeight: "18px",
fontWeight: 500,
whiteSpace: "nowrap",
color: waiting ? "inherit" : confirmColor,
}}
>
{label}
</motion.span>
</AnimatePresence>
</div>
</div>
);
}About this pattern
One circle serves both states. While the request is in flight a quarter arc turns on it; when the response lands, the arc completes the rotation it was already in — not cut short — then extends to a closed ring, shifts to the confirm color, and the angled stroke lands inside. Because nothing is swapped out, there is no frame where the badge is empty and no jump between two glyphs with different optical weights. It also stays deliberately flat: no pop, no bounce. A save indicator fires dozens of times an hour, and anything celebratory becomes noise by the twentieth time you see it.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Document page
The save indicator cycles while a page is syncing and resolves into a settled state in the same spot.
Related patterns
- Optimistic Row InsertThe new entry lands at half opacity the instant it is asked for, then firms up when the server agrees.
- File Upload ProgressA file row's bar fills as bytes land while the percentage and the counter roll with it.
- Staggered List EntranceRows fade and lift into place about fifty milliseconds apart, top to bottom.