Skip to content
Component installs need the registry setup— the Base UI shadcn project, the @vegastack namespace and the Cloudflare Access service token.
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

Add Auto Save Input from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.

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

The same command installs the registry items it composes: @vegastack/input, @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 wraps Input. 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 itselfonSave 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.

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.

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.
classNamestring | ((state: InputState) => string | undefined)Classes for the Base UI input element. Accepts Base UI's state-function form, so styles can respond to field state such as focused or invalid.
containerClassNamestringClasses for the wrapper used only when prefix or suffix is present.
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.
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.
prefixReact.ReactNodeContent rendered as a non-editable addon before the input (e.g. "app.vegastack.com/" or an icon). Switches the component into addon mode: the <input> is wrapped in a bordered group and the border/ring/disabled styling moves to the wrapper. Plain strings render as muted, non-selectable label text.
size"lg" | "md" | "sm"'md'Control height on the shared 28/32/40 scale (--size-sm/md/lg), matching Button and Select. (The native numeric size attribute is intentionally replaced by this variant prop.)
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).
valuestringControlled 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.

Data attributes and CSS variables on AutoSaveInput

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

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", or "Save failed" as hidden text while the visible icon remains decorative.
  • 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 Input recolors its border with the ring token (focus:border-ring/(--alpha-tint-border)) instead of removing the outline — never outline: none with no replacement affordance. (Like the standalone field, this is a border recolor, not a 2px ring.)
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