Auto Save Input
An input that debounces edits and persists them via an async onSave, with an inline idle/saving/saved/error status.
- Status
- 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-inputThe 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 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.
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
onSaveholds the spinningLoaderin flight. - Saved — a fast
onSaveresolves to thetext-success-textCheck. - Error (rejecting onSave) — a rejected promise flags the
text-destructive-textXand setsaria-invalid. - Error (failed validate) — clearing the field fails
validate, which flagserrorwithout ever callingonSave.
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
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
className | string | ((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. |
containerClassName | string | — | Classes for the wrapper used only when prefix or suffix is present. |
debounceMs | number | 800 | Debounce delay in milliseconds between the last keystroke and the onSave
call. Keystrokes within the window reset the timer. |
defaultValue | string | '' | 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. |
prefix | React.ReactNode | — | Content 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). |
value | string | — | 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. |
Data attributes and CSS variables on AutoSaveInput
| Attribute | Values |
|---|---|
data-slot | "auto-save-input" | "auto-save-input-status" |
data-state | mirrors 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).
| Prop | Type | Default | Description |
|---|---|---|---|
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 usehtmlFor/id); usearia-labelonly 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
errorstatus setsaria-invalidon the field, matchingInput's invalid styling and announcing the failure to screen readers. - The spinner respects
prefers-reduced-motion(motion-reduce:animate-none). - On focus the underlying
Inputrecolors its border with theringtoken (focus:border-ring/(--alpha-tint-border)) instead of removing the outline — neveroutline: nonewith no replacement affordance. (Like the standalone field, this is a border recolor, not a 2px ring.)
| Key | Action |
|---|---|
| Any text key | Edits the value and (re)starts the debounce timer. |
Tab | Moves focus to/from the field as a normal input. |
| Contract | States tested |
|---|---|
| Behaviour | default, active, disabled, empty, error, invalid, saved, saving, success |
| Accessibility | disabled, invalid, labeled, live, status-announcement, semantic-html |
| Visual | default, invalid, error, success, empty |
Do / Don't
Chip Input
Free-token entry — Enter or comma commits, delimited pastes split into chips, and per-chip validation keeps invalid entries visible instead of dropping them.
Dropzone
File acquisition — drop, browse, or paste — as a thin shell over the use-file-drop hook; the drop surface is the named, focusable control.