Save Indicator
Feedback

Save Indicator

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.

Install

npx shadcn@latest add @paragon/save-indicator

save-indicator.tsx

"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>
  );
}