Ansora UI
Primitives

Save status

Whether what the user typed exists off this machine — five states, one recovery action slot, and a live region that speaks only while something is wrong.

statusprimitive

Install

pnpm dlx shadcn@4.23.0 add @ansora/save-status

Examples

Usage

Five states, and what the user can do in each:

  1. idle — nothing to report yet. Renders no pill, no time and no action, but it stays mounted. A live region announces only changes made while it is already in the document, so {dirty && <SaveStatus … />} is silent on the very first failure. Mount this with the editor, not with the first edit; that is what idle is for.
  2. unsaved — edits exist that only this machine knows about. Neutral, because an autosave is coming. This is the state a navigation guard reads. The component does not block navigation — beforeunload plus a router guard is window, routing and a product decision about which states block, none of which belongs in a status indicator.
  3. saving — a request is in flight. No timestamp, and deliberately no animation: an animated glyph is unreadable on a contact sheet.
  4. saved — the silent normal. lastSaved and recoveredLabel are both required here alone, for the same reason: "Uloženo" with no time answers nothing, and a recovery with no word for it announces nothing. See the announcement policy below.
  5. error — the save failed. action is required: a critical state with no way out is a component telling a doctor their work is gone and offering nothing. lastSaved survives into this state on purpose — "poslední uložení 14:07" answers how much am I about to lose, and nothing else on the screen answers it.

Why five states and not eight. The first draft also carried offline, conflict and expired, each with its own tone, glyph and claim on the user — and named a second screen for none of them. The rule here is that anything justifiable from a single product is screen-level and belongs in that product's repo, so those three were cut. unsaved was kept because two products need it and their needs differ out loud: in the video product notes are typed while the patient is talking, so the indicator must stay quiet and must never open a dialog mid-call; in the scribe the consultation is over and cannot be replayed, so losing the draft is irreversible and the indicator has to escalate. The trigger that brings the cut three back is written down rather than left to taste: the first product screen that writes its own variant of error, with its own action, for the second time. Until then, map them onto error with the action that fits.

Tone is derived from state and is not a prop. A caller who can set it will set it wrong, and tone here encodes demand on the user, which falls out of the state with nothing left over. saved is neutral, not green: green is the brand colour and never a state, and "the file is where you left it" is the silent normal. Every state also carries a non-hue glyph, so the pill does not rely on colour alone.

What the live region says, and when. The region carries text only while the user must act — today that is error — and is empty otherwise. That is structural rather than a matter of discipline: the ordinary unsaved → saving → saved cycle fires on every debounce, i.e. several utterances per typed sentence, aimed at the person doing the typing. There is no "assertive", and no such member in the props type; role, aria-live and aria-atomic are typed never so a call site cannot widen the policy from outside. A page that arrives already in error fills the region on its first render. saved speaks exactly once, on recovery — removing content from a polite region announces nothing, which is why recoveredLabel is a required prop rather than an optional one: without that string a blind user would learn that saving broke and never learn it was fixed.

The recovery survives the states in between, because your retry path goes through them. The action this component requires in error produces error → saving → saved, not a direct error → saved. A problem stays outstanding across every intermediate state and is cleared only when the recovery is actually announced, so error → saved → unsaved → saving → saved speaks on the second saved rather than losing the news to a dwell that was cut short. Leaving saved before the dwell elapses cancels that dwell and nothing else — the news is kept, only its timing is.

The bound the recovery delay buys is one utterance per ESCALATION, not one per incident. A recovery is announced only after it has held for five seconds, so a two-second flap on ward Wi-Fi costs one utterance instead of two. Escalations are never delayed: error → saved → error still speaks twice, because the second failure is new information. Five seconds is an engineering guess — nothing in this design system can hear a screen reader.

Derive error from a FAILED REQUEST, never from navigator.onLine. It returns true on a captive portal, which is exactly the ward Wi-Fi case: the browser is on a network and the save is going nowhere. This component does not save, does not debounce, does not retry and does not look at the network; all of that is your data layer, and a status indicator that guessed would be a data layer wearing a label.

Map 401 onto error with an action reading "Přihlásit znovu", and stop the retry loop when you do. A retry loop that keeps going will 401 forever under the word "Ukládám…", which is the one failure mode where the indicator actively lies.

stateLabel is a whole sentence, and the number carries its own caption. stateLabel is what the region says, so it cannot double as the lead-in to a figure: stateLabel="Uložení selhalo, poslední uložení" makes a screen reader stop immediately before the number it was leading to. The caption travels with the timestamp inside lastSaved instead ({ at, label: "poslední uložení" }), which is why the timestamp cannot be rendered without one.

If a @ansora/session-bar is on the same screen, one of you has to stay quiet about the network. A dropped connection ends capture and fails a save, so both regions speak, politely and in a queue, about one cause. This component has no announce switch — that is deliberate — so the screen's two levers are announce="off" on the bar, or not rendering this component while capture is what failed. The four announcement policies in this design system and how they compose are written down in one place: the ansora-visual-language skill, references/accessibility-qa.md §12.

A list of these should not exist — and if it does, it gets one region, not forty. Unlike @ansora/session-bar, which defaults to announce="polite" because one bar changes a handful of times per session, this component has no announce switch at all: save status is per-document, so the receptionist's forty-row list has no reason to carry forty of them. If a screen ever does, do not render forty; announce the list's own state once, from the screen.

Positioning is yours: the component ships no sticky, no position and no ms-auto. Pass className where the screen wants it. It is "use client" (it owns a timer and four pieces of view state), so action is a ReactNode slot rather than an onRetry callback — one slot instead of six props whose Czech labels are the caller's anyway.

Budget 4 elements and 5 DOM nodes for a bare saving status, and 8 elements and 13 DOM nodes for an error with a timestamp and an action. Both figures come from a real react-dom/server render rather than a hand count, and apps/docs/save-status-runtime.test.tsx compares them against this paragraph on every pnpm check — change the markup without changing these numbers and the suite goes red, and vice versa. Read that as scoped to the numbers. The announcement policy above is not gated to the same standard and must not be assumed to be: no test in this repo executes a state transition, so the recovery memory, the dwell and the flap suppression are checked by reading and by source-text guards, never by running them. The one announcement claim a test really does compare against output is which subtree each node landed in — the region empty in the quiet states, filled on the first render of error, with the timestamp and the action outside it.

import { SaveStatus } from "@/components/ui/ansora/save-status"

<SaveStatus
  state="error"
  stateLabel="Uložení selhalo"
  lastSaved={{ at: lastSavedAt, label: "poslední uložení" }}
  action={<Button size="sm" onClick={retry}>Zkusit znovu</Button>}
/>

Files

Dependencies

  • @ansora/base
  • @ansora/status-pill

On this page