Sync Status Rotate
A sync glyph turns slowly while changes upload, then stops on a tick.
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 · Sync Status Rotate
*
* The saving indicator in a document header: a two-arc glyph turns
* slowly while changes upload, then stops on a tick as the label
* crossfades to "All changes saved".
*
* Self-contained: depends only on `motion` (react ships with your app).
* The chip is mixed from the inherited text color, so it reads correctly
* on a light page and on a dark one.
* Works with zero props; tune via `variant`, `status`, the labels.
* Requires the automatic JSX runtime (default since React 17).
*/
export type SyncStatusRotateProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/**
* Drive it from your own sync state. "auto" cycles the two phases,
* which is what a live document actually does all day.
*/
status?: "auto" | "syncing" | "synced";
syncingLabel?: string;
syncedLabel?: string;
/** In "auto": how long each phase holds, in ms. */
syncingMs?: number;
syncedMs?: number;
};
type VariantConfig = {
/** Seconds per full turn while syncing. */
spinSeconds: number;
/** How the tick lands once the turning stops. */
spring: { type: "spring"; stiffness: number; damping: number };
/** Crossfade between the two glyphs and the two labels. */
swapSeconds: number;
};
// Damping ratios (damping / 2√stiffness) all sit at or above 0.8. This
// indicator lives in a toolbar the reader glances at mid-sentence, so
// the tick has to arrive settled — variants change pace, never bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Slow turn, flat landing. For a chrome slot you should barely notice.
subtle: {
spinSeconds: 2.4,
spring: { type: "spring", stiffness: 520, damping: 46 },
swapSeconds: 0.16,
},
// Readable turn, one soft settle. The all-purpose setting.
default: {
spinSeconds: 1.8,
spring: { type: "spring", stiffness: 440, damping: 38 },
swapSeconds: 0.18,
},
// Quicker turn for a sync people are actively waiting on.
playful: {
spinSeconds: 1.2,
spring: { type: "spring", stiffness: 380, damping: 32 },
swapSeconds: 0.2,
},
};
const DONE_COLOR = "#2FA36B";
/** Theme-adaptive neutral: mixing the text color in scope with
* `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function SyncStatusRotate({
variant = "default",
status = "auto",
syncingLabel = "Saving changes",
syncedLabel = "All changes saved",
syncingMs = 2400,
syncedMs = 1800,
}: SyncStatusRotateProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// Controlled use reads straight off the prop; only "auto" keeps state,
// so there is nothing to synchronize back and forth.
const [autoPhase, setAutoPhase] = useState<"syncing" | "synced">("syncing");
const phase = status === "auto" ? autoPhase : status;
useEffect(() => {
if (status !== "auto") return;
const timer = setTimeout(
() =>
setAutoPhase((current) => (current === "syncing" ? "synced" : "syncing")),
phase === "syncing" ? syncingMs : syncedMs
);
return () => clearTimeout(timer);
}, [status, phase, syncingMs, syncedMs]);
const done = phase === "synced";
// The wider label reserves the chip's width, so the crossfade never
// resizes the toolbar under the reader's eye.
const widest =
syncedLabel.length >= syncingLabel.length ? syncedLabel : syncingLabel;
return (
<div
role="status"
aria-live="polite"
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "6px 12px 6px 10px",
borderRadius: 999,
background: tone(6),
border: `1px solid ${tone(12)}`,
fontSize: 12.5,
}}
>
<span
aria-hidden
style={{
position: "relative",
width: 16,
height: 16,
flexShrink: 0,
}}
>
<AnimatePresence initial={false}>
{done ? (
<motion.span
key="done"
initial={
reduceMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.55, rotate: -18 }
}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
exit={{
opacity: 0,
transition: { duration: cfg.swapSeconds, ease: "easeOut" },
}}
transition={
reduceMotion
? { duration: cfg.swapSeconds, ease: "easeOut" }
: {
...cfg.spring,
opacity: { duration: cfg.swapSeconds, ease: "easeOut" },
}
}
style={{ position: "absolute", inset: 0, display: "block" }}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M4.2 8.4 6.9 11.1 11.8 5.5"
stroke={DONE_COLOR}
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</motion.span>
) : (
// Two nested elements on purpose: the outer one owns arrival
// and departure, the inner one owns the endless turn. Leaving
// on a decelerating curve is what makes the pair read as one
// glyph coming to rest rather than two icons swapping.
<motion.span
key="syncing"
initial={{ opacity: 0 }}
animate={{ opacity: 0.55 }}
exit={{
opacity: 0,
scale: reduceMotion ? 1 : 0.72,
transition: { duration: cfg.swapSeconds, ease: "easeOut" },
}}
transition={{ duration: cfg.swapSeconds, ease: "easeOut" }}
style={{ position: "absolute", inset: 0, display: "block" }}
>
<motion.span
// Reduced motion: the glyph stays put. The label and the
// tick still carry the state, so nothing is lost.
animate={reduceMotion ? undefined : { rotate: 360 }}
transition={{
duration: cfg.spinSeconds,
repeat: Infinity,
ease: "linear",
}}
style={{ display: "block", width: 16, height: 16 }}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M2.7 7.1A5.4 5.4 0 0 1 13.3 7.1"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
/>
<path
d="M12.7 10.7A5.4 5.4 0 0 1 3.3 10.7"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
/>
</svg>
</motion.span>
</motion.span>
)}
</AnimatePresence>
</span>
<span style={{ position: "relative", display: "inline-block" }}>
{/* Invisible sizer: the chip is as wide as its longest state, so
no label change can nudge the toolbar. */}
<span aria-hidden style={{ visibility: "hidden", whiteSpace: "nowrap" }}>
{widest}
</span>
{[
{ text: syncingLabel, active: !done },
{ text: syncedLabel, active: done },
].map((entry) => (
<motion.span
key={entry.text}
aria-hidden={!entry.active}
initial={false}
animate={{ opacity: entry.active ? (done ? 0.85 : 0.6) : 0 }}
transition={{ duration: cfg.swapSeconds, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
whiteSpace: "nowrap",
pointerEvents: "none",
}}
>
{entry.text}
</motion.span>
))}
</span>
</div>
);
}About this pattern
The saving indicator that lives in a document header. While changes are in flight a two-arc glyph turns on a linear loop — no easing, because an eased turn implies a progress it does not know. When the upload lands the glyph leaves on a decelerating curve as the tick springs in, so the pair reads as one mark coming to rest rather than two icons swapping. The label crossfades inside a slot sized to its longest state, so the toolbar never shifts while someone is mid-sentence.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Document page
Header status swaps between saving and saved without moving the title.
Related patterns
- Save Indicator SettleA turning ring under "Saving" resolves into a drawn tick under "Saved", then the chip recedes.
- Changes Saved PillA pill drifts up from the toolbar to confirm an autosave, then dissolves.
- Connection RestoredThe offline bar turns green, confirms, and retracts in one continuous move.