The small inline "Saving… / Saved 2 minutes ago / Couldn't save · Retry" indicator that sits beside an autosaving surface, and the announcement that goes with it. Use it wherever edits persist in the background instead of behind a Save button: a document, note or rich-text editor (Tiptap, ProseMirror, Lexical, Slate, Quill), a code editor pane (Monaco, CodeMirror), a settings, profile or account page that saves on blur, a draft post, email or message composer, a form with debounced autosave, a spreadsheet-style inline-edit table, a kanban card or CRM record, a form builder, page builder or design canvas, a collaborative document backed by a CRDT (Yjs, Automerge, Liveblocks), and anything wired to a background mutation — a TanStack Query mutation's isPending and isError, a Next.js server action behind useActionState or useFormStatus, react-hook-form with autosave, tRPC, SWR, Convex, Firebase or Supabase writes. Common asks it answers: "autosave indicator", "saving spinner next to the title", "all changes saved", "changes saved automatically", "draft saved status", "last saved timestamp", "saving saved error state", "Google-Docs-style save state", "Notion-style saving indicator", "how to show saving/saved/error", "autosave status react", "save state component", "shadcn autosave indicator", "shadcn save status". Official shadcn/ui ships nothing for this, and the parts that look close are all a different shape: sonner and toast pop a transient message after an action and then leave, so nothing on screen still says the document is saved a minute later; spinner covers a single in-flight request and has no resting, saved or failed state; badge renders a label but carries no state machine, no relative time and no live region; and field and message report validation on a control rather than the fate of a background write. You pass one `status` prop (idle | saving | saved | error). idle renders nothing visible, so it can be rendered unconditionally and simply mirror your mutation state rather than being mounted and unmounted around it. Pass `savedAt` and "Saved" is followed by a live relative timestamp that keeps itself fresh — it composes the time-ago component instead of freezing a string that goes stale while the tab sits open, which is the first thing a hand-rolled one gets wrong. Pass `onRetry` and the error state grows a Retry button. Every label (savingLabel, savedLabel, errorLabel, retryLabel) is overridable for i18n. Accessibility is the fiddly part, and it is the reason to take this rather than write it. The wording lives in an always-mounted role="status" region, so the very first transition is actually announced — a region that mounts at the same moment as its text is registered too late and its first "Saving…" is dropped silently, which is exactly what conditional rendering produces. The ticking timestamp and the Retry label sit outside that region on purpose: inside it, the timer would make the page announce "Saved 3 minutes ago" every minute, unprompted, for as long as the tab is open. And it stays polite rather than assertive even for failures, because aria-live is honoured at registration time and a failed autosave should not cut across someone mid-sentence. Distinct from toast, which is a transient notification after an explicit action, and from spinner and loading-button, which cover one request in flight: this is the persistent, resting status of a background save. Styled with shadcn tokens (muted-foreground, destructive, ring) so it follows light and dark themes; it composes time-ago and uses lucide-react for its icons.
pnpm dlx shadcn@latest add "https://pulld.pages.dev/r/save-status.json"