Changes Saved Pill
A pill drifts up from the toolbar to confirm an autosave, then dissolves.
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 · Changes Saved Pill
*
* The receipt for an autosave: a small pill drifts up out of the
* toolbar, rests just long enough to be read, then keeps drifting as
* it dissolves.
*
* Self-contained: depends only on `motion` (react ships with your app).
* The pill follows the host app's color scheme, so it lands light on a
* light page and dark on a dark one.
* Works with zero props; tune via `variant`, `label`, `holdMs`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ChangesSavedPillProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
label?: string;
/** Quiet second half of the line — pass an empty string to drop it. */
meta?: string;
/** How long the pill rests before it leaves, in ms. */
holdMs?: number;
/** Fires once the pill has finished leaving. */
onDismiss?: () => void;
};
type VariantConfig = {
/** px the pill rises from as it arrives. */
rise: number;
/** px it keeps travelling as it dissolves. */
drift: number;
spring: { type: "spring"; stiffness: number; damping: number };
exitSeconds: number;
};
// Damping ratios (damping / 2√stiffness) stay at or above 0.8. This pill
// confirms something the reader did not ask about, so it must not bob
// for attention. Variants differ in travel and pace, never in bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A few pixels of drift. For an editor that saves every keystroke.
subtle: {
rise: 4,
drift: 3,
spring: { type: "spring", stiffness: 570, damping: 48 },
exitSeconds: 0.2,
},
// Enough drift to register in peripheral vision. All-purpose.
default: {
rise: 10,
drift: 7,
spring: { type: "spring", stiffness: 420, damping: 38 },
exitSeconds: 0.26,
},
// A longer arc for a save the reader has been waiting on.
playful: {
rise: 16,
drift: 11,
spring: { type: "spring", stiffness: 340, damping: 31 },
exitSeconds: 0.32,
},
};
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 ChangesSavedPill({
variant = "default",
label = "Changes saved",
meta = "just now",
holdMs = 1800,
onDismiss,
}: ChangesSavedPillProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [open, setOpen] = useState(true);
useEffect(() => {
const timer = setTimeout(() => setOpen(false), holdMs);
return () => clearTimeout(timer);
}, [holdMs]);
// Reduced motion: the pill still arrives and still leaves, it just
// does not travel to do it.
const rise = reduceMotion ? 0 : cfg.rise;
const drift = reduceMotion ? 0 : cfg.drift;
return (
<AnimatePresence onExitComplete={onDismiss}>
{open && (
<motion.div
role="status"
aria-live="polite"
initial={{ opacity: 0, y: rise }}
animate={{ opacity: 1, y: 0 }}
exit={{
opacity: 0,
// It keeps going the way it came in, so the whole life of the
// pill is one upward drift rather than an arrival and a retreat.
y: -drift,
transition: { duration: cfg.exitSeconds, ease: "easeIn" },
}}
transition={
reduceMotion
? { duration: 0.18, ease: "easeOut" }
: {
...cfg.spring,
// Opacity on its own quick curve; springing it looks muddy.
opacity: { duration: 0.18, ease: "easeOut" },
}
}
// Translate and opacity only. Scaling the pill would scale the
// words inside it, which is the one thing text must never do.
style={{
display: "inline-flex",
alignItems: "center",
gap: 7,
padding: "6px 12px 6px 10px",
borderRadius: 999,
fontSize: 12.5,
// The pill sits over page content, so the surface is opaque.
// `Canvas`/`CanvasText` are the CSS system colors for page
// background and page text: they follow the host app's color
// scheme, so the pill is light in a light app and dark in a
// dark one. Everything inside then mixes from `currentColor`.
background: "Canvas",
color: "CanvasText",
border: `1px solid ${tone(13)}`,
boxShadow: "0 8px 22px rgba(0,0,0,0.16)",
whiteSpace: "nowrap",
}}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
<circle
cx="8"
cy="8"
r="6.5"
stroke={DONE_COLOR}
strokeWidth="1.5"
opacity="0.5"
/>
<path
d="M5.2 8.2 7.1 10.1 10.9 6"
stroke={DONE_COLOR}
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span style={{ fontWeight: 550 }}>{label}</span>
{meta ? <span style={{ opacity: 0.45 }}>{meta}</span> : null}
</motion.div>
)}
</AnimatePresence>
);
}About this pattern
Confirmation for something the reader never asked to be told. The pill rises a few pixels out of the toolbar on a flat spring, rests exactly long enough to be read, and then keeps drifting the same direction as it fades — so its whole life is one upward move rather than an arrival followed by a retreat. It has no dismiss control and no timer, because a save is not a decision. The surface uses the CSS system colors, so it stays opaque and legible over the document in either color scheme.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Issue tracker
Short confirmations appear next to the control that caused them and leave without a dismiss button.
Related patterns
- Save Indicator SettleA turning ring under "Saving" resolves into a drawn tick under "Saved", then the chip recedes.
- Clipboard Toasts StackRepeat copies push a short stack of confirmations that shuffle down and dim instead of piling up.
- Sync Status RotateA sync glyph turns slowly while changes upload, then stops on a tick.