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…

"use client"

import * as React from "react"
import { AlertCircle, Check, Loader2, RotateCw } from "lucide-react"
import { cn } from "@/lib/utils"

/** One entrance animation: fade in with a 2px rise. React 19 hoisted <style>, deduped by href across instances. */
const KEYFRAMES = `@keyframes si-in{from{opacity:0;transform:translateY(2px)}to{opacity:1;transform:none}}`

const MINUTE = 60_000
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR

/**

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/save-indicator.json

Prompt

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 minDuration and parks the early "saved" on a pendingSaved flag, 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 hideAfter dwell before fading; failure is not, so error is deliberately left off the auto-hide path and stays until the user retries or the consumer changes status. Two different lifetimes on one component is the whole point.
  • Phase vs status — the public status describes the network; the internal phase describes the screen and carries the extra bookkeeping (minElapsed, pendingSaved, hidden) that makes rapid saving → saved → saving sequences resolve in order instead of scrambling.
  • Fade-out needs a mounted elementhidden exists purely so the Saved badge can play its exit at opacity-0 instead of being deleted from the DOM mid-animation; a consumer-driven reset to idle skips it, so no phantom "Saved" is ever invented after an error.
  • Clock as external store — the relative timestamp reads a floored Date.now() through useSyncExternalStore, which keeps render pure, keeps the snapshot stable enough that React stops re-rendering, and makes SSR and hydration agree via a null server snapshot.
  • Dual live region — one permanently mounted role="status" and one role="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.

On This Page