Skip to content
Component installs need the registry setup
VegaStack Design

Auto Save Input

An input that debounces edits and persists them via an async onSave, with an inline idle/saving/saved/error status.

Status
stable
Since
0.1.0
Accessibility pattern
native input + status region

Last updated

Install

Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Auto Save Input before installation. Retain the digest printed by this command.

pnpm exec vegastack-design verify auto-save-input

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/auto-save-input

Run the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.

The same command installs the registry items it composes: @vegastack/input-group, @vegastack/spinner.

Usage

import { AutoSaveInput } from "@/components/ui/auto-save-input";

<AutoSaveInput
  aria-label="Workspace name"
  defaultValue={workspace.name}
  onSave={async (name) => {
    await updateWorkspace({ name });
  }}
  validate={(v) => v.trim().length > 0}
/>;

AutoSaveInput composes InputGroup around an InputGroupInput — upstream's Input flattened into the box — so the trailing status indicator sits in a real addon slot rather than on top of a padded field. It owns its value as local state from defaultValue (or follows value + onValueChange when controlled), debounces keystrokes, and calls onSave(value) once the field settles. A trailing indicator reflects the outcome: a spinner while saving, a success check once saved, and an error cross on failure.

Presentational component

The component owns only the debounce timer and the inline status UI. It does not persist anything itself — onSave is yours, and so is everything around it (success/error toasts, optimistic cache updates, cross-field effects). There is no toast coupling; if you want a toast, fire it from onSave or onStatusChange. This split keeps the component reusable across apps while the app keeps the side effects.

Anatomy

AutoSaveInput — data-slot="auto-save-input" | "auto-save-input-status"
AutoSaveIndicator — data-slot="auto-save-indicator" | "auto-save-indicator-text"

Examples

A failing onSave (rejected promise) flags the error state and sets aria-invalid; a failing validate skips the save entirely and flags error without ever calling onSave. Edit the field below and pause — it saves after 800ms; clear it and the validator blocks the save.

States

Status is owned by the component and only advances when the field is edited, so this is a live example — type into each field and pause for the debounce to watch the trailing indicator move. Each field is wired to a different outcome:

  • Saving — a deliberately slow onSave holds the spinning Loader in flight.
  • Saved — a fast onSave resolves to the text-success-text Check.
  • Error (rejecting onSave) — a rejected promise flags the text-destructive-text X and sets aria-invalid.
  • Error (failed validate) — clearing the field fails validate, which flags error without ever calling onSave.
Saving (slow onSave)
Saved (fast onSave)
Error (rejecting onSave)
Error (failed validate, never calls onSave)

Controlled value

Pass value + onValueChange to let the parent own the draft. External value changes — like switching records — are treated as a new saved baseline, so moving between records never auto-saves stale data and never re-flags a status. Edit the field to save after the debounce, or switch records to reset the baseline.

Editing saves after the debounce; switching records resets the baseline.

Status indicator

AutoSaveIndicator is the same lifecycle as a line of text for an action row: a spinner and "Saving…", a check and "Saved", and the -text ink with its own icon for "Couldn't save" and a conflict ("Not saved"). It is a polite live region, empty while idle.

Saving…SavedCouldn't saveNot saved

Icon only

variant="icon" is the same status as one 16px icon for a field's trailing slot — an InputGroupAddon align="inline-end" on an input or textarea, or beside a select: a muted spinner while saving, a text-success-text check once saved, a text-destructive-text warning triangle when the save failed (text-warning-text on a conflict). Each state pops in (a fade and a 0.9 → 1 scale on the motion tokens), the check fades out over duration-slow when the status returns to idle, and the box is always reserved, so nothing shifts. The wording ("Saving", "Saved", "Couldn't save") stays as screen-reader text in the live region; reduced motion swaps instantly.

SavingsavingSavedsavedCouldn't saveerrorNot savedconflict

An invalid value

A value the field rejects (an empty required name) is not a save state: nothing was sent. Pass invalid and the indicator — either variant — drops the tick and says Not saved in the warning ink, so a field never shows "saved" beside its own error. useInvalidRevert finishes the job: leave the field while it is invalid and, a second later, onRevert puts the last saved value back and shows why ("Name can't be empty"); coming back to the field, or fixing the value, cancels it. AutoSaveInput does both itself — a value validate rejects reads "Not saved", and onInvalidRevert turns on the revert.

In a top bar

compact="narrow" keeps a text indicator to one line and, below sm or in a narrow AppShellHeader (under 40rem), shows the icon alone — the words are still read out. Use it for a document's connection state ("Reconnecting…") among a top bar's actions.

<AutoSaveIndicator
  status="connecting"
  compact="narrow"
  labels={{ connecting: "Reconnecting…" }}
/>

A whole form (useAutoSave)

useAutoSave saves a form object rather than one field: debounced (600ms), only the changed fields, one save in flight at a time with the latest values sent next, a version carried for optimistic concurrency (isConflict turns a stale-version error into the conflict status), and a flush() to call before a step change or navigation. Pending changes are also saved on unmount and when the tab closes.

No saves yet

API Reference

PropTypeDefaultDescription
onSave*(value: string) => Promise<void>—Async persistence callback invoked after the debounce window when the value changed. Resolve to flag saved; reject (or throw) to flag error. The status indicator reflects the outcome inline — the app may also react here (e.g. fire a toast), but the component never couples to one.
credentialbooleantruefalse (FRM-17) marks a name-like field that is not a credential — a record, table or field name — so browsers and password managers stop offering to fill it: autoComplete="off", data-1p-ignore, data-lpignore="true", data-bwignore. An explicit autoComplete wins.
debounceMsnumber800Debounce delay in milliseconds between the last keystroke and the onSave call. Keystrokes within the window reset the timer.
defaultValuestring''Initial value for uncontrolled use. The component owns the draft from here on and compares typed input against the last saved value to decide whether a save is needed.
onInvalidRevert((savedValue: string) => void)—Leaving the field while validate rejects its value puts the last saved value back after revertDelay, then calls this with it — show the host's message here ("Name can't be empty"). Omit to leave the invalid value in place.
onStatusChange((status: AutoSaveStatus) => void)—Fired whenever the save status changes. Use it to drive surrounding UI (disable a submit button, etc.) without re-deriving the state yourself.
onValueChange((value: string) => void)—Fired whenever the draft value changes. Required for controlled value usage; optional for uncontrolled defaultValue usage.
revertDelaynumber1000Milliseconds after the field is left before an invalid value is reverted.
size"default" | "lg" | "sm" | null—
validate((value: string) => boolean)—Optional synchronous guard run before saving — return false to skip the save and surface the error status (e.g. empty or malformed input).
valuestring—Controlled value of the field. Pair with onValueChange so user edits are mirrored by the parent. External value changes are treated as a new saved baseline, so switching records never auto-saves stale data.
variant"default" | "ghost" | null—

Data attributes and CSS variables on AutoSaveInput

AttributeValues
data-slot"auto-save-input" | "auto-save-input-status"
data-statemirrors a prop or state value

AutoSaveIndicator

PropTypeDefaultDescription
status*AutoSaveIndicatorStatus—The status to show — useAutoSave().status, or a live document's connection (connecting, offline). idle shows nothing.
compact"narrow"—text only: where the wording yields to the icon alone (still read out). narrow — below sm and in a narrow AppShellHeader (under 40rem), for a status in a top bar's actions.
invalidbooleanfalseThe field's value is invalid, so it is not being saved: the tick and spinner give way to a visible "Not saved" in the warning ink — in both variants, the icon variant included — so a field never shows "saved" beside its own error. Whatever status says.
labelsPartial<Record<"invalid" | ShownStatus, string>>{ saving: 'Saving…', saved: 'Saved', error: "Couldn't save", conflict: 'Not saved', connecting: 'Connecting…', offline: 'Offline — changes will sync', invalid: 'Not saved' } (icon: 'Saving', 'Saved', "Couldn't save", 'Not saved', 'Connecting', 'Offline — changes will sync', 'Not saved')Replaces the default wording per status — the visible line for text, the screen-reader label for icon (a document kept on the device says so: offline: "Offline — saved on this device") — and invalid, which both variants show.
variant"icon" | "text"'text'text — icon and wording, for an action row. icon — the icon alone in a fixed 16px box, for a field's trailing slot (InputGroupAddon align="inline-end", or beside a select): a muted spinner while saving or connecting, a muted check once saved (it fades out when the status returns to idle), a warning-ink triangle on error or conflict, and a warning-ink cloud-off while offline. The wording stays as screen-reader text in the live region.

Data attributes and CSS variables on AutoSaveIndicator

AttributeValues
data-compactmirrors a prop or state value
data-invalid""
data-slot"auto-save-indicator" | "auto-save-indicator-text"
data-statemirrors a prop or state value
data-variant"icon"

useAutoSave

PropTypeDefaultDescription
onSave*(changes: Partial<T>, context: { version: number | undefined; }) => Promise<{ version?: number; } | void>—Persists the CHANGED fields only — the keys whose value differs from what was last saved. Receives the version the host last reported (or the previous save returned) for optimistic concurrency; resolve with { version } to advance it, reject to flag an error.
value*T—The current values. Every change starts (or restarts) the debounce.
delaynumber600Quiet time after the last change before saving, in milliseconds.
enabledbooleantrueWhether saving is on. Turning it on takes the current value as the saved baseline, so a record that has just been created is not saved a second time.
isConflict((error: unknown) => boolean)—Recognises a stale-version error, which becomes the conflict status.
versionnumber—The record's version as the host knows it. A higher value (another write moved the record on) is adopted before the next save; a lower one is ignored.

useInvalidRevert

PropTypeDefaultDescription
invalid*boolean—The field's value is invalid now — the value that would be saved is not.
onRevert*() => void—Puts the last saved value back in the field — and shows the host's message ("Name can't be empty"). Called once, delay after the field is left while still invalid.
delaynumber1000Milliseconds after the field is left before the value is reverted.

AutoSaveStatus is the union of states the trailing indicator can reflect; pass onStatusChange to react to transitions (e.g. disable a submit button while saving).

PropTypeDefaultDescription
AutoSaveStatus"idle" | "saving" | "saved" | "error"—Lifecycle of an auto-save: idle (no pending change), saving (onSave in flight), saved (last save resolved), error (rejected or failed validation).

Accessibility

  • Renders a native <input> — always associate a visible <label> (wrap it or use htmlFor/id); use aria-label only when a visible label is impossible.
  • Status is conveyed by a distinct icon per state (spinner / check / cross) paired with a semantic color token, so it never depends on color alone.
  • A single role="status" aria-live="polite" aria-atomic="true" slot announces "Saving", "Saved", "Save failed", or "Not saved" (a value validate rejects) as hidden text while the visible icon remains decorative.
  • AutoSaveIndicator invalid shows "Not saved" as visible text, in the same live region, never a check beside an error.
  • The error status sets aria-invalid on the field, matching Input's invalid styling and announcing the failure to screen readers.
  • The spinner respects prefers-reduced-motion (motion-reduce:animate-none).
  • On focus the underlying InputGroup darkens its border subtly (border-ring/50, easing in over 150ms) and paints no fill — text entry's exception to FOC-14 (MK 2026-09-27) — instead of removing the affordance; never outline: none with nothing in its place. The error status keeps its destructive border while focused.
KeyAction
Any text keyEdits the value and (re)starts the debounce timer.
TabMoves focus to/from the field as a normal input.
ContractStates tested
Behaviourdefault, active, disabled, empty, error, invalid, saved, saving, success
Accessibilitydisabled, invalid, labeled, live, status-announcement, semantic-html
Visualdefault, invalid, error, success, empty

Do / Don't

Do
Use defaultValue for an uncontrolled draft, value/onValueChange for a controlled draft, and keep persistence/toasts in onSave/onStatusChange.
Don't
Name an initial uncontrolled value value, or rely on color alone for save status.

On this page