Autosave status line — spinner while in flight, a check on landing, then a relative timestamp that decays and pauses its ticks while the tab is hidden.
npx shadcn@latest add @paragon/save-indicator"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
Check,
CloudOff,
LoaderCircle,
TriangleAlert,
} from "lucide-react";
import { cn } from "@/lib/utils";
export type SaveStatus = "idle" | "saving" | "saved" | "offline" | "error";
export interface SaveIndicatorProps extends React.ComponentProps<"div"> {
status?: SaveStatus;
/** When the last save landed; drives the relative-time decay. */
savedAt?: number | Date;
savingText?: string;
offlineText?: string;
errorText?: string;
idleText?: string;
}
const spinnerStyles = `
@keyframes pg-save-spin { to { rotate: 360deg; } }
@keyframes pg-save-pulse { 50% { opacity: 0.4; } }
`;
function relativeLabel(savedAt: number, now: number): string {
const elapsed = Math.max(0, now - savedAt);
if (elapsed < 10_000) return "just now";
if (elapsed < 60_000) return `${Math.floor(elapsed / 1000)}s ago`;
if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)}m ago`;
if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)}h ago`;
return `${Math.floor(elapsed / 86_400_000)}d ago`;
}
/**
* Autosave status line: a spinner while the write is in flight, a check the
* moment it lands, then a relative timestamp that quietly decays ("just now"
* → "45s ago" → "2m ago"). Words swap through a blur crossfade and the icons
* through the house pop-swap, so state changes read as one continuous
* object. The decay interval skips ticks while the tab is hidden and
* re-syncs on return. The whole line is a polite live region.
*/
export function SaveIndicator({
status = "saved",
savedAt,
savingText = "Saving…",
offlineText = "Offline — changes saved locally",
errorText = "Couldn't save",
idleText = "All changes saved",
className,
...props
}: SaveIndicatorProps) {
const reducedMotion = useReducedMotion();
const savedAtMs =
savedAt === undefined
? undefined
: typeof savedAt === "number"
? savedAt
: savedAt.getTime();
// `now` starts equal to savedAt (deterministic — no Date.now in render);
// effects advance it, pausing while the document is hidden.
const [now, setNow] = React.useState(savedAtMs ?? 0);
React.useEffect(() => {
if (status !== "saved" || savedAtMs === undefined) return;
const sync = () => {
if (!document.hidden) setNow(Date.now());
};
sync();
const interval = setInterval(sync, 10_000);
document.addEventListener("visibilitychange", sync);
return () => {
clearInterval(interval);
document.removeEventListener("visibilitychange", sync);
};
}, [status, savedAtMs]);
const label =
status === "saving"
? savingText
: status === "offline"
? offlineText
: status === "error"
? errorText
: status === "saved"
? savedAtMs === undefined
? "Saved"
: `Saved ${relativeLabel(savedAtMs, Math.max(now, savedAtMs))}`
: idleText;
const icon =
status === "saving" ? (
<LoaderCircle
className="size-3.5"
style={{
animation: reducedMotion
? "pg-save-pulse 1.6s ease-in-out infinite"
: "pg-save-spin 0.8s linear infinite",
}}
/>
) : status === "offline" ? (
<CloudOff className="size-3.5" />
) : status === "error" ? (
<TriangleAlert className="size-3.5" />
) : (
<Check className="size-3.5" />
);
const spring = { type: "spring", duration: 0.3, bounce: 0 } as const;
return (
<div
data-slot="save-indicator"
role="status"
aria-live="polite"
className={cn(
"inline-flex h-6 items-center gap-1.5 text-[13px]",
status === "error" ? "text-destructive" : "text-muted-foreground",
className,
)}
{...props}
>
<style href="paragon-save-indicator" precedence="paragon">
{spinnerStyles}
</style>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={status === "saved" || status === "idle" ? "ok" : status}
initial={
reducedMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.25, filter: "blur(4px)" }
}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={
reducedMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.25, filter: "blur(4px)" }
}
transition={spring}
aria-hidden
className={cn(
"flex shrink-0 items-center justify-center",
(status === "saved" || status === "idle") && "text-success",
status === "error" && "text-destructive",
)}
>
{icon}
</motion.span>
</AnimatePresence>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={label}
initial={
reducedMotion
? { opacity: 0 }
: { opacity: 0, y: 4, filter: "blur(4px)" }
}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={
reducedMotion
? { opacity: 0 }
: { opacity: 0, y: -4, filter: "blur(4px)" }
}
transition={spring}
className="whitespace-nowrap tabular-nums"
>
{label}
</motion.span>
</AnimatePresence>
</div>
);
}