Skip to content

Saving, saved, offline and failed states in one pill that springs to fit.

Formsmotion

01Preview

Q3 planning notes
SavedSaved

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install motion

03Usage

import { AutosaveStatus, useOnline } from "@/components/ui/autosave-status";

const online = useOnline();

<AutosaveStatus
  status={!online ? "offline" : saving ? "saving" : failed ? "error" : "saved"}
  savedAt={lastSavedAt}
  pending={queue.length}
  onRetry={flush}
/>

04Source

"use client";
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react";
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
import { cn } from "@/lib/cn";
import { ease, spring } from "@/lib/motion";

export type AutosaveState = "idle" | "saving" | "saved" | "offline" | "error";

/** Whether the browser thinks it is online, kept current. Renders as online on the server. */
export function useOnline() {
  return useSyncExternalStore(
    (cb) => {
      window.addEventListener("online", cb);
      window.addEventListener("offline", cb);
      return () => {
        window.removeEventListener("online", cb);
        window.removeEventListener("offline", cb);
      };
    },
    () => navigator.onLine,
    () => true,
  );
}

export type AutosaveStatusProps = Omit<React.ComponentProps<"div">, "children"> & {
  status: AutosaveState;
  /** When the last save landed. Drives “Saved 2m ago”, refreshed every 30 seconds. */
  savedAt?: Date | number | null;
  /** Changes waiting while offline, e.g. “3 changes waiting”. */
  pending?: number;
  /** Shown after “Couldn’t save”. Omit to hide the button. */
  onRetry?: () => void;
  /** The spinner holds at least this long, so a quick save never flickers. */
  minSaving?: number;
  locale?: string;
};

// A save that finishes in 80ms still shows "Saving…" for a readable moment.
function useHeldStatus(status: AutosaveState, minSaving: number) {
  const [shown, setShown] = useState(status);
  const since = useRef(0);
  useEffect(() => {
    if (status === shown) return;
    if (status === "saving") since.current = performance.now();
    const wait = shown === "saving" ? Math.max(0, minSaving - (performance.now() - since.current)) : 0;
    const t = window.setTimeout(() => setShown(status), wait);
    return () => window.clearTimeout(t);
  }, [status, shown, minSaving]);
  return shown;
}

function useNow(active: boolean) {
  const [now, setNow] = useState<number | null>(null);
  useEffect(() => {
    if (!active) return;
    const tick = () => setNow(Date.now());
    const first = window.setTimeout(tick, 0);
    const id = window.setInterval(tick, 30_000);
    return () => {
      window.clearTimeout(first);
      window.clearInterval(id);
    };
  }, [active]);
  return now;
}

function relative(then: number, now: number, locale?: string) {
  const s = Math.round((then - now) / 1000);
  const f = new Intl.RelativeTimeFormat(locale, { style: "narrow", numeric: "auto" });
  if (s > -45) return "just now";
  if (s > -3600) return f.format(Math.round(s / 60), "minute");
  if (s > -86400) return f.format(Math.round(s / 3600), "hour");
  return new Intl.DateTimeFormat(locale, { day: "numeric", month: "short" }).format(then);
}

export function AutosaveStatus({ status, savedAt, pending, onRetry, minSaving = 600, locale, className, ...rest }: AutosaveStatusProps) {
  const reduce = useReducedMotion();
  const shown = useHeldStatus(status, minSaving);
  const at = savedAt == null ? null : typeof savedAt === "number" ? savedAt : savedAt.getTime();
  const now = useNow(shown === "saved" && at != null);
  const when = at != null && now != null ? relative(at, now, locale) : null;

  const text =
    shown === "saving"
      ? "Saving…"
      : shown === "saved"
        ? "Saved"
        : shown === "offline"
          ? pending
            ? `Offline · ${pending} ${pending === 1 ? "change" : "changes"} waiting`
            : "Offline, will retry"
          : shown === "error"
            ? "Couldn’t save"
            : "";
  const announce = shown === "saving" ? "" : shown === "saved" ? "Saved" : text;

  // The pill's width springs to fit each state, measured from an invisible copy of the content.
  const sizer = useRef<HTMLSpanElement>(null);
  const width = useMotionValue(-1);
  const cssWidth = useTransform(width, (w) => (w < 0 ? "auto" : w));
  useLayoutEffect(() => {
    const w = sizer.current?.offsetWidth ?? 0;
    if (width.get() < 0 || reduce) width.set(w);
    else animate(width, w, spring.snappy);
  }, [shown, when, pending, width, reduce]);

  const content = (s: AutosaveState, live: boolean) => (
    <>
      {s !== "idle" && <Glyph state={s} reduce={!!reduce} live={live} />}
      <span className={cn("whitespace-nowrap", s === "error" ? "text-danger" : s === "saved" ? "text-fg-2" : "text-fg-3")}>{text}</span>
      {s === "saved" && when && (
        <time
          suppressHydrationWarning
          dateTime={at ? new Date(at).toISOString() : undefined}
          title={at ? new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(at) : undefined}
          className="whitespace-nowrap text-fg-3 tabular"
        >
          {when}
        </time>
      )}
      {s === "error" && onRetry && <span className="w-[42px]" aria-hidden />}
    </>
  );

  return (
    <div data-state={shown} className={cn("relative inline-flex h-7 items-center", className)} {...rest}>
      <motion.span
        className="relative block h-7 overflow-hidden"
        style={{ width: cssWidth }}
        animate={{ opacity: shown === "idle" ? 0 : 1 }}
        transition={{ duration: 0.2 }}
      >
        <span ref={sizer} aria-hidden className="invisible absolute left-0 top-0 inline-flex h-7 items-center gap-1.5 px-0.5 text-[12px]">
          {content(shown, false)}
        </span>
        <AnimatePresence initial={false} mode="popLayout">
          <motion.span
            key={shown}
            className="absolute left-0 top-0 inline-flex h-7 items-center gap-1.5 px-0.5 text-[12px]"
            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: "blur(2px)" }}
            animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: "blur(2px)", transition: { duration: 0.14, ease: ease.in } }}
            transition={{ duration: reduce ? 0.15 : 0.24, ease: ease.out }}
          >
            {content(shown, true)}
          </motion.span>
        </AnimatePresence>
      </motion.span>
      {/* The retry sits outside the clipped pill so its focus ring and hit area are never cut. */}
      {shown === "error" && onRetry && (
        <motion.button
          type="button"
          onClick={onRetry}
          initial={reduce ? { opacity: 0 } : { opacity: 0, x: -4 }}
          animate={{ opacity: 1, x: 0 }}
          transition={{ duration: 0.2, ease: ease.out, delay: reduce ? 0 : 0.08 }}
          className={cn(
            "absolute right-0.5 rounded-[4px] text-[12px] font-medium text-fg underline decoration-fg-4 underline-offset-[3px] outline-none",
            "transition-[text-decoration-color] duration-150 hover:decoration-fg-2",
            "focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 focus-visible:outline-solid",
            "before:absolute before:-inset-x-2 before:-inset-y-2.5 before:content-['']",
          )}
        >
          Retry
        </motion.button>
      )}
      <span role="status" aria-live="polite" className="sr-only">
        {announce}
      </span>
    </div>
  );
}

const svg = { width: 14, height: 14, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeLinecap: "round" as const, strokeLinejoin: "round" as const, "aria-hidden": true };

function Glyph({ state, reduce, live }: { state: AutosaveState; reduce: boolean; live: boolean }) {
  const draw = live && !reduce;
  if (state === "saving")
    return (
      <svg {...svg} strokeWidth={1.5} className="shrink-0 animate-spin text-fg-3 [animation-duration:0.8s]">
        <circle cx="8" cy="8" r="5.75" opacity="0.25" />
        <path d="M8 2.25a5.75 5.75 0 0 1 5.75 5.75" />
      </svg>
    );
  if (state === "saved")
    return (
      <svg {...svg} strokeWidth={1.7} className="shrink-0 text-success">
        <motion.path
          d="M3.5 8.5 6.5 11.5 12.5 4.5"
          initial={draw ? { pathLength: 0 } : false}
          animate={{ pathLength: 1 }}
          transition={{ duration: 0.32, ease: ease.out, delay: 0.06 }}
        />
      </svg>
    );
  if (state === "offline")
    return (
      <span className="relative grid size-3.5 shrink-0 place-items-center" aria-hidden>
        <span className="size-1.5 rounded-full bg-warning" />
        {live && <span className="absolute size-1.5 animate-ping-soft rounded-full bg-warning [animation-duration:2.4s]" />}
      </span>
    );
  if (state === "error")
    return (
      <svg {...svg} strokeWidth={1.5} className="shrink-0 text-danger">
        <circle cx="8" cy="8" r="5.75" />
        <motion.path d="M8 5v3.5" initial={draw ? { pathLength: 0 } : false} animate={{ pathLength: 1 }} transition={{ duration: 0.2, ease: ease.out }} />
        <circle cx="8" cy="10.9" r=".6" fill="currentColor" stroke="none" />
      </svg>
    );
  return null;
}

05Props

AutosaveStatus

PropTypeDefaultDescription
status*"idle" | "saving" | "saved" | "offline" | "error"What the document is doing. idle collapses the pill to nothing.
savedAtDate | number | nullWhen the last save landed. Shows “just now”, “3m ago”, then a date; refreshed every 30s, with the exact time on hover.
pendingnumberEdits queued while offline: “Offline · 3 changes waiting”.
onRetry() => voidAdds a Retry button after “Couldn’t save”.
minSavingnumber600The spinner holds at least this many ms, so quick saves don’t flicker.
localestringFor the relative and absolute times. Defaults to the browser’s.

useOnline

PropTypeDefaultDescription
returnsbooleanThe browser’s online state, updated on online/offline events. true during server render.

06Notes

Behavior

  • A save that finishes instantly still reads “Saving…” for 600ms before the tick, so the status never strobes while typing.
  • Offline keeps the reassurance specific: how many edits are waiting, not a generic warning.
  • The relative time is computed after mount and ticks every 30s, so server and client never disagree about “now”.
  • Retry sits outside the clipped pill, so its focus ring and 44px touch area are never cut.

Motion

  • The pill’s width springs (snappy: 520 stiffness, 38 damping) to each state’s measured width, so the header beside it glides instead of jumping.
  • Labels slide 6px through with a 2px blur (240ms in, 140ms out); the tick draws in 320ms and the offline dot breathes on a slow 2.4s ping.
  • Reduced motion keeps 150ms fades, a still spinner and an instant width.

Accessibility

  • Saved, offline and failure states are announced through a polite live region; Saving… is not, to keep typing quiet.
  • The time is a real <time> with an ISO datetime and the full date and time as its title.
  • State is carried by words as well as color and icon.