Feedback
Save Indicator
The autosave line from a document editor's toolbar — Saving… holds a minimum beat, Saved fades back to "Last saved 3 minutes ago", and an error stays put with a Retry.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/save-indicator.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "SaveIndicator" component (lucide-react for
icons; no other dependencies). It is the autosave status line that lives in a
document editor's toolbar: "Saving… / Saved / Couldn't save / Last saved 3
minutes ago".
Contract
- Export a forwardRef div extending
Omit<React.HTMLAttributes<HTMLDivElement>, "children">.
- Props (defaults):
- status: "idle" | "saving" | "saved" | "error" = "idle" — fully controlled by
the consumer's save pipeline. The component never starts a save itself.
- lastSavedAt?: Date | null = null — when present, the idle state reads
"Last saved 3 minutes ago".
- hideAfter = 2000 (ms) — how long "Saved" lingers before fading back to idle.
0 pins it forever.
- minDuration = 500 (ms) — the minimum time "Saving…" stays on screen. 0
disables the guard.
- variant: "inline" | "pill" = "inline" — bare text row vs bordered chip.
- showIcon = true — icons for the three status messages (the idle
"Last saved …" line is deliberately icon-free).
- onRetry?: () => void — only when provided does the error state render a
Retry button. Omit it and the error is text-only; never render a dead button.
- locale = "en-US" — explicit BCP 47 tag for Intl.RelativeTimeFormat. Never
call Intl.*(undefined): server and visitor default locales differ and the
hydrated text would not match.
- savingLabel = "Saving…", savedLabel = "Saved",
errorLabel = "Couldn't save", retryLabel = "Retry",
lastSavedPrefix = "Last saved", justNowLabel = "just now" — flat string
props so the whole surface can be translated without a nested config object.
- className merged last via cn(); remaining props spread on the root div.
Behavior — the display state machine (this is the whole component)
- Keep an INTERNAL phase that is richer than the public `status`, because the
public status is a fact about the network and the phase is a fact about the
screen:
{ idle } | { saving, minElapsed, pendingSaved } | { saved } | { hidden } |
{ error }
- Map incoming status changes with a RENDER-PHASE adjust (the official React
prevProps pattern: keep `prevStatus` in state, compare during render, call
setState there). Do NOT do this in an effect — it would show one frame late
and React's set-state-in-effect lint rule forbids it.
- -> "saving": phase = { saving, minElapsed: minDuration <= 0,
pendingSaved: false } and bump a `runId` counter.
- -> "saved" while phase is saving AND minElapsed is false: do NOT show
Saved yet — only flip pendingSaved on the SAME phase object. This is what
keeps a fast round-trip from flashing "Saving…" for 80ms, and because it
does not touch runId it also does not restart the timer (restarting is the
classic bug: "Saving…" ends up held for 2x minDuration).
- -> "saved" otherwise: phase = { saved }.
- -> "error": phase = { error }, immediately. The minimum-duration guard
exists to stop flicker, not to delay bad news.
- -> "idle": phase = { idle }.
- Timer 1 (minimum duration), effect keyed on [runId, minDuration]: one timer
per saving run; the cleanup cancels the previous run's timer, so rapid
saving -> saved -> saving never stacks two timers. On fire, a functional
setState resolves: still saving && pendingSaved -> { saved }; still saving ->
mark minElapsed; anything else -> untouched (a save that already errored out
must not be resurrected by a late timer).
- Timer 2 (auto-hide), effect keyed on [phase.kind, hideAfter]: only while
phase is `saved` and hideAfter > 0, then { saved } -> { hidden }. `error` is
NOT on this path, which is exactly why an error never disappears on its own.
- `hidden` vs `idle` exist separately for one reason: the fade-out. In `hidden`
the Saved badge stays mounted at opacity 0 so it can actually fade instead of
being yanked from the DOM; in `idle` (the consumer explicitly reset, or a
reset after an error) there is nothing to fade and no phantom "Saved" is
invented. If lastSavedAt is set, `hidden` renders the relative line instead
and the badge simply cross-fades into it.
- Every timer id lives inside its effect and is cleared by that effect's
cleanup, so unmount and every re-entry are covered. There is no async
continuation anywhere (no await, no fetch), so no mounted flag is needed — and
if you add one later, set it to true IN THE EFFECT BODY, not only false in the
cleanup, or StrictMode's mount/cleanup/mount leaves it false forever.
- Clamp both durations: non-finite or negative -> 0. NaN must never reach
setTimeout.
Behavior — the relative timestamp
- "Last saved 3 minutes ago" is derived from `lastSavedAt` plus a ticking clock.
Render must never call Date.now() / new Date() (react-hooks/purity). Read the
clock through useSyncExternalStore instead:
- subscribe: setInterval(onStoreChange, 15000), cleared on unsubscribe, and
a no-op subscription when lastSavedAt is absent (do not run a timer for a
label nobody is showing).
- getSnapshot: Math.floor(Date.now() / 15000) * 15000. The rounding is not
cosmetic — getSnapshot must return the same value twice in a row or React
re-renders forever chasing a moving number.
- getServerSnapshot: null. Server render and the hydrating client render
both take the "no clock yet" branch, so there is no hydration mismatch;
React re-reads the real snapshot right after hydration on its own.
- Because the snapshot is floored, elapsed time can come out slightly negative
right after a save — clamp it to 0 and read it as justNowLabel.
- Formatting: under a minute -> justNowLabel; otherwise Intl.RelativeTimeFormat
with numeric:"auto", stepping minutes -> hours -> days and carrying on the
ROUNDED value (guard against "60 minutes ago" / "24 hours ago").
Rendering & styling
- Root: inline-flex items-center gap-1.5 text-xs leading-none, plus
transition-opacity duration-200. variant="pill" adds
rounded-full border bg-card px-2.5 py-1 shadow-sm; the error pill switches to
border-destructive/40 bg-destructive/10.
- Text tone by phase: saving/idle text-muted-foreground, saved text-foreground,
error text-destructive. Semantic tokens only — no hex, no rgb(), no oklch(),
and no chart tokens on text (the default palette is monochrome, chart tokens
would be invisible in one theme).
- Icons (size-3.5): Loader2 with animate-spin for saving, Check for saved,
AlertCircle for error. All aria-hidden.
- One content slot at natural width. The status badge carries key={phase} so
each new message replays a 200ms fade+2px-rise keyframe shipped in a React 19
hoisted <style href precedence="medium"> tag; the "Last saved …" span uses a
FIXED key so ticking the minute counter does not replay the animation.
- Reduced motion: motion-reduce:transition-none on the root,
motion-reduce:[animation:none] on the entering content, and
motion-reduce:animate-none on the spinner. The status still changes — only the
movement is dropped.
- Accessibility — two permanently mounted sr-only live regions, one
role="status" aria-live="polite" and one role="alert", with the message routed
to whichever matches the phase. Two regions instead of one node that swaps its
role, because swapping role on a live node makes assistive tech re-register
and drop the announcement. The VISIBLE status text is aria-hidden so it is not
read twice. The "Last saved …" line is deliberately NOT inside a live region:
it re-renders every minute and would otherwise be announced every minute.
- The Retry button is a real <button type="button"> outside the aria-hidden
subtree, with focus-visible:ring-2 ring-ring; it is the only interactive part
and it only exists when onRetry was passed.
- The indicator holds its slot rather than collapsing: when the Saved badge
fades out with no lastSavedAt to fall back to, the root goes
opacity-0 pointer-events-none and keeps its width, so the toolbar around it
never reflows.
Customization levers
- Placement: variant="pill" for a chip floating over a toolbar or a canvas,
variant="inline" to sit inside an existing text row (it inherits font-size if
you drop the text-xs).
- Timing: minDuration is the anti-flicker floor (raise to ~800ms if your
backend is very fast and the label still reads as noise), hideAfter is the
success dwell (0 pins "Saved" for a page that has no idle line to fall back
to). Neither knob touches the error path.
- Wording / i18n: the six label props plus `locale` cover the whole surface;
swap them wholesale for another language and Intl handles the relative unit.
- Timestamp granularity: the 15s clock tick is a module constant — raise it to
60000 for a calmer page, lower it if you want "just now" to expire quickly.
It is also the rounding step of the snapshot, so keep the two identical.
- Icons: replace the three lucide glyphs, or pass showIcon={false} for a
text-only line; the layout does not depend on the icon being present.
- Adding a state (e.g. "offline"): extend the public union, add one phase
branch in the render-phase adjust, one entry in the label/icon maps and one
tone class — the timer effects only care about `saving` and `saved`, so they
stay untouched.
- Wiring: keep the component controlled. Drive `status` from your mutation
layer (react-query onMutate/onSuccess/onError maps 1:1) and set `lastSavedAt`
to a fresh Date in the success handler; `onRetry` should re-run that same
mutation.Concepts
- Minimum-duration flicker guard — a 60ms round-trip that flashes "Saving…" for two frames reads as a glitch, not as feedback. The guard holds the saving message for
minDurationand parks the early "saved" on apendingSavedflag, so the message is always legible and the total never stretches to twice the floor. - Auto-hide success, sticky error — success is disposable and gets a
hideAfterdwell before fading; failure is not, soerroris deliberately left off the auto-hide path and stays until the user retries or the consumer changesstatus. Two different lifetimes on one component is the whole point. - Phase vs status — the public
statusdescribes the network; the internal phase describes the screen and carries the extra bookkeeping (minElapsed,pendingSaved,hidden) that makes rapidsaving → saved → savingsequences resolve in order instead of scrambling. - Fade-out needs a mounted element —
hiddenexists purely so the Saved badge can play its exit atopacity-0instead of being deleted from the DOM mid-animation; a consumer-driven reset toidleskips it, so no phantom "Saved" is ever invented after an error. - Clock as external store — the relative timestamp reads a floored
Date.now()throughuseSyncExternalStore, which keeps render pure, keeps the snapshot stable enough that React stops re-rendering, and makes SSR and hydration agree via anullserver snapshot. - Dual live region — one permanently mounted
role="status"and onerole="alert", message routed to whichever fits, instead of one node that swaps its role (assistive tech re-registers and loses the announcement). The relative timestamp stays outside both regions so it is not read aloud every minute.