Async Action Button
A button that survives a real round trip — a minimum pending duration, a width that never twitches, aria-disabled instead of disabled.
- Category
- Components
- Added
- 2026-09-21
- Deps
- none
- Updated
- 2026-09-21
This is a viberdy Pro component
The live preview, the props panel and the write-up are open to everyone. The full source, the AI prompt and CLI install are part of Pro.
Props
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
type State = "idle" | "pending" | "done" | "error";
export function AsyncActionButton({
label = "Save changes",
pendingLabel = "Saving",
doneLabel = "Saved",
errorLabel = "Failed — retry",
minPending = 450,
resultFor = 1400,
onAction,
}: {
label?: string;
pendingLabel?: string;
doneLabel?: string;
// ---------------------------------------------------------------
// Full source available with a viberdy Pro subscription.
// https://viberdy.dev/pricing
// ---------------------------------------------------------------
}About this component
Every product has this button and most of them are subtly broken in the same three ways. The first is the one that defines the component: when the action resolves quickly — a cached response, a local mutation, anything under about a tenth of a second — the pending state exists for two frames, and a spinner that appears and vanishes reads as a rendering fault rather than as progress. The perverse part is that it only misbehaves on fast connections, so it survives every test on a throttled profile. Giving the pending state a minimum lifetime once it has been shown means it is either legible or never seen. The second is width: idle, pending and done labels are three different lengths, so swapping the text makes the button twitch and shoves whatever sits next to it, which in a form footer jumps the whole row. Stacking every label into a single CSS grid cell with one visible sizes the button to the longest of them permanently, without measuring anything. The third is the disabled attribute, which is reached for automatically and is the wrong tool: a disabled button leaves the accessibility tree and loses focus, silently dumping a keyboard user back to the top of the document in the middle of the action they just started. Marking it aria-disabled and aria-busy while ignoring the click keeps it focusable and keeps the user where they were. Re-entry is latched with a ref rather than state, because state updates asynchronously and a fast second click otherwise fires the action twice.
Curator’s note
The spinner that flashes for two frames only happens on fast connections, which is precisely where nobody tests. Give the pending state a floor and it is either readable or invisible.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Optimistic Inline Commit
componentsAn instant-feedback control with the parts everyone skips: request sequencing and a rollback that restores the right value.
Form Error Summary
patternsThe error pattern that actually works with a screen reader: focus the summary, link to the fields, punish late.
Undo Commit Toast
patternsAn undo window that defers the action instead of reversing it, and pauses when you tab toward the button.