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.
DOM + CSS0 deps1 file · 6 KB TSX
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
// ---------------------------------------------------------------
}Details
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.


