Form Autosave Indicator
A field that kept itself pulses its border once and writes the time underneath.
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, useId, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Form Autosave Indicator
*
* A field that keeps itself. When the pause after typing is long enough,
* the border pulses once and a timestamp fades in underneath — the whole
* receipt for a save that happened without a button.
*
* Self-contained: depends only on `react` and `motion`. Surfaces are mixed
* from the inherited text color, so the field reads correctly on a light
* page and on a dark one.
* Works with zero props; tune via `variant`, `label`, `debounceMs`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type FormAutosaveIndicatorProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Field label, also used as the accessible name. */
label?: string;
/** Starting content of the field. */
defaultValue?: string;
/** Quiet time after the last keystroke before the save starts. */
debounceMs?: number;
/** How long the save itself is pretended to take. */
saveMs?: number;
/** Accent for the pulse, the tick and focus. */
accent?: string;
/** Overall width. */
width?: number | string;
/** Fires each time a save completes. */
onSaved?: (value: string) => void;
};
type VariantConfig = {
/** Seconds for the single border pulse. */
pulse: number;
/** Peak opacity of that pulse. */
peak: number;
/** Seconds for the status line to change. */
fade: number;
/** How far the timestamp rises as it appears, in px. */
rise: number;
tick: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: one pulse, never a repeat. A border that throbs turns a
// routine save into an alarm, and this is the most frequent event in the
// whole form — it fires every time the user pauses. The tick spring sits
// above a 0.8 damping ratio, and the status line is text: it fades and
// drifts a few pixels, it never scales.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Almost subliminal. For a long form that saves constantly.
subtle: {
pulse: 0.5,
peak: 0.55,
fade: 0.14,
rise: 3,
tick: { type: "spring", stiffness: 600, damping: 48 },
},
// Clearly noticed, still quiet. The all-purpose setting.
default: {
pulse: 0.66,
peak: 0.8,
fade: 0.18,
rise: 4,
tick: { type: "spring", stiffness: 520, damping: 42 },
},
// A slower, fuller pulse for a single prominent field.
playful: {
pulse: 0.82,
peak: 1,
fade: 0.22,
rise: 6,
tick: { type: "spring", stiffness: 440, damping: 36 },
},
};
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const SAMPLE =
"Ships with the March release. Covers the new export formats and the audit log filters.";
type Status = "idle" | "saving" | "saved";
function relativeLabel(secondsAgo: number) {
if (secondsAgo < 10) return "Saved moments ago";
if (secondsAgo < 60) return `Saved ${Math.floor(secondsAgo / 10) * 10}s ago`;
return `Saved ${Math.floor(secondsAgo / 60)}m ago`;
}
export default function FormAutosaveIndicator({
variant = "default",
label = "Release notes",
defaultValue = SAMPLE,
debounceMs = 900,
saveMs = 700,
accent = "#5B5BD6",
width = 320,
onSaved,
}: FormAutosaveIndicatorProps) {
const [value, setValue] = useState(defaultValue);
const [status, setStatus] = useState<Status>("idle");
const [focused, setFocused] = useState(false);
/** Bumped per completed save so the pulse element remounts and replays. */
const [pulseKey, setPulseKey] = useState(0);
const [savedAt, setSavedAt] = useState<number | null>(null);
const [secondsAgo, setSecondsAgo] = useState(0);
/** Set on mount too: the field arrives with an edit already pending, so
* the save cycle plays once without waiting to be typed in. */
const [pending, setPending] = useState(true);
const fieldId = useId();
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
useEffect(() => {
if (!pending) return;
const toSaving = setTimeout(() => setStatus("saving"), debounceMs);
const toSaved = setTimeout(() => {
setStatus("saved");
setSavedAt(Date.now());
setSecondsAgo(0);
setPulseKey((key) => key + 1);
setPending(false);
onSaved?.(value);
}, debounceMs + saveMs);
return () => {
clearTimeout(toSaving);
clearTimeout(toSaved);
};
// Every keystroke changes `value`, which tears down the pending timers
// and schedules new ones — that restart is the debounce. `onSaved` is
// deliberately not a dependency: an inline callback from the parent
// would otherwise reset the clock on each render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pending, value, debounceMs, saveMs]);
// The timestamp has to keep being true after it is written, so it ages
// on a slow interval rather than being frozen at the moment of the save.
useEffect(() => {
if (savedAt === null) return;
const timer = setInterval(
() => setSecondsAgo(Math.round((Date.now() - savedAt) / 1000)),
10000
);
return () => clearInterval(timer);
}, [savedAt]);
const edit = (next: string) => {
setValue(next);
setStatus("idle");
setPending(true);
};
const statusText =
status === "saving"
? "Saving"
: status === "saved"
? relativeLabel(secondsAgo)
: "Unsaved changes";
return (
<div style={{ width, color: "inherit" }}>
<label
htmlFor={fieldId}
style={{
display: "block",
marginBottom: 6,
fontSize: 11.5,
fontWeight: 650,
letterSpacing: 0.3,
opacity: 0.55,
}}
>
{label.toUpperCase()}
</label>
<div style={{ position: "relative" }}>
<textarea
id={fieldId}
value={value}
rows={3}
onChange={(event) => edit(event.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
style={{
display: "block",
width: "100%",
padding: "11px 12px",
boxSizing: "border-box",
borderRadius: 12,
border: `1px solid ${focused ? accent : tone(13)}`,
background: tone(5),
color: "inherit",
fontFamily: "inherit",
fontSize: 13,
lineHeight: 1.55,
resize: "none",
outline: "none",
boxShadow: focused
? `0 0 0 3px color-mix(in srgb, ${accent} 24%, transparent)`
: "0 0 0 0 transparent",
transition:
"border-color 160ms ease-out, box-shadow 160ms ease-out",
}}
/>
{/* The receipt: one pass of an accent border over the field, keyed
so each save replays it and nothing ever repeats on a loop. */}
{pulseKey > 0 && !reduceMotion && (
<motion.span
key={pulseKey}
aria-hidden
initial={{ opacity: 0 }}
animate={{ opacity: [0, cfg.peak, 0] }}
transition={{
duration: cfg.pulse,
times: [0, 0.24, 1],
ease: "easeOut",
}}
style={{
position: "absolute",
inset: -1,
borderRadius: 13,
border: `1.5px solid ${accent}`,
pointerEvents: "none",
}}
/>
)}
</div>
<div
role="status"
aria-live="polite"
style={{
display: "flex",
alignItems: "center",
gap: 6,
minHeight: 18,
marginTop: 8,
fontSize: 11.5,
}}
>
<motion.span
initial={false}
animate={{ scale: status === "saved" ? 1 : 0.4, opacity: status === "saved" ? 1 : 0 }}
transition={reduceMotion ? { duration: 0 } : cfg.tick}
style={{
display: "grid",
placeItems: "center",
width: 14,
height: 14,
flex: "0 0 auto",
borderRadius: "50%",
background: `color-mix(in srgb, ${accent} 20%, transparent)`,
color: accent,
}}
>
<svg
width="9"
height="9"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="m4.5 10.5 3.8 3.8L15.5 6" />
</svg>
</motion.span>
{/* Re-keyed on the wording so each state fades in on its own. The
text moves a few pixels and never changes size. */}
<motion.span
key={statusText}
initial={
reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.rise }
}
animate={{ opacity: status === "saved" ? 0.75 : 0.5, y: 0 }}
transition={{ duration: reduceMotion ? 0 : cfg.fade, ease: "easeOut" }}
style={{ fontWeight: 550 }}
>
{statusText}
</motion.span>
</div>
</div>
);
}About this pattern
When there is no save button, the field has to answer the question the button used to answer. A single pass of an accent border around the box that was stored says which field it was — a global chip in a corner never does — and the timestamp underneath says when, then keeps aging so it stays true minutes later. One pulse, never a repeat: this fires every time the user stops typing, and a border that throbs turns the most routine event in the form into an alarm. The status is written as well as animated, so a screen reader and a reduced-motion visitor get the same receipt everyone else does.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Document page
Edits are kept as they happen, with no explicit submit anywhere.
Related patterns
- Required Field MarkUnanswered fields mark themselves one after another down the form — a wave, not a jolt.
- Sync Status RotateA sync glyph turns slowly while changes upload, then stops on a tick.
- Character Count LimitA small ring closes as the field fills, shows what is left once the cap is in sight, and tints once when the text runs past it.