Loading Buttons Without Lying: Pending States That Actually Communicate
Spinners inside buttons became a reflex, not a decision. Here's how to design and build pending states that tell the truth about what's happening — and what to do when the network takes longer than you promised.
Every design system ships a button with a spinner inside it, and almost every team stops thinking about pending states right there. The spinner spins, the user waits, and when something goes wrong — slow network, duplicate submit, server hiccup — the UI quietly lies about what's happening. Pending states are a product problem, not a decorative one.
The three questions a pending state has to answer
Before we touch markup, let's get the requirements straight. When a user clicks a button and something async starts, the UI has to answer three things, in order of urgency:
- Did my click register? (within ~100ms)
- Is the system actually working on it? (within ~1s)
- Should I keep waiting, or did this die? (after ~3–5s)
Most loading buttons answer question one well, question two passably, and question three not at all. That third gap is where support tickets come from.
Why "just add a spinner" fails
A spinner is a universal symbol for "something is happening somewhere," which is exactly the problem. It doesn't distinguish between "your request is in flight," "the server received it and is processing," and "we lost the connection two seconds ago and I'm still spinning out of habit." Users learn, correctly, that spinners can lie. So they double-click. Then you have a duplicate payment.
A pending state taxonomy worth stealing
In our client work we categorize button async states into four buckets, and each one gets a different treatment:
- Instant (< 200ms): Don't show a pending state at all. Flicker is worse than nothing.
- Short (200ms – 2s): Disable interaction, show in-button spinner, keep the label.
- Medium (2s – 10s): Swap the label to a verb-in-progress ("Saving…"), lock the surrounding form, consider an inline progress hint.
- Long (> 10s): Move out of the button entirely. Show a progress indicator, allow cancellation, send the result somewhere persistent (toast, email, notification center).
The mistake is treating all four with the same <Button loading> prop. A payment confirmation and a tag filter are not the same interaction.
The 200ms threshold trick
Showing a spinner immediately on click causes a visible flash on fast networks. Instead, delay the pending visual by 150–200ms. If the request resolves before then, the user never sees a loading state — the UI just updates. If it takes longer, the spinner appears when it's actually useful.
function useDelayedPending(isPending: boolean, delay = 180) {
const [show, setShow] = useState(false);
useEffect(() => {
if (!isPending) {
setShow(false);
return;
}
const t = setTimeout(() => setShow(true), delay);
return () => clearTimeout(t);
}, [isPending, delay]);
return show;
}
Pair this with a minimum display time (around 400ms) once the spinner does appear, so it never flashes-and-vanishes. Users read a flash as a bug.
Writing labels that don't infantilize the user
"Loading…" is a lazy label. The button already said what it was going to do; the pending state should confirm that work is in progress using the same verb.
| Idle label | Bad pending label | Good pending label |
|---|---|---|
| Save changes | Loading… | Saving… |
| Place order | Please wait | Placing order… |
| Send invite | Submitting | Sending… |
| Delete account | Loading… | Deleting… |
The ellipsis is doing real work here — it signals ongoing action without needing a spinner to carry the whole message. For destructive actions especially, keep the verb visible. A spinner where "Delete account" used to be is genuinely scary; "Deleting…" is reassuring because it confirms the user's intent is being honored.
Preserve button width
Swapping "Save" (34px wide) for "Saving…" (62px wide) causes layout shift. Lock the button to its widest state:
.btn {
min-width: max-content;
/* or set an explicit min-width based on the longest label */
}
Or render both labels and toggle visibility, keeping the DOM width stable. Small detail, but a jittery form is a form users distrust.
The accessibility bits almost everyone skips
A disabled-looking button with a spinner is not accessible by default. Screen readers need to know three things: the button is busy, the label has changed, and when it's safe to interact again.
<button
type="submit"
aria-busy={isPending}
aria-disabled={isPending}
disabled={isPending}
onClick={handleSubmit}
>
<span aria-hidden={isPending}>Save changes</span>
<span aria-hidden={!isPending}>
<Spinner aria-hidden="true" />
Saving…
</span>
</button>
A few notes on this pattern:
aria-busytells assistive tech the control is working. Use it alongsidedisabled, not instead of.- The spinner itself gets
aria-hidden— it's decorative. The word "Saving…" carries the meaning. - Some teams prefer
aria-disabledover the nativedisabledattribute so the button stays focusable. That's a reasonable choice if you want users to be able to tab to it and read its state; just make sure your click handler bails when pending. - Announce completion with a polite live region (
role="status"oraria-live="polite"), not with the button itself. "Changes saved" belongs in a status area.
Contrast matters too. A disabled button with 2:1 contrast is unreadable; WCAG doesn't technically require disabled controls to meet contrast, but your users still have to see them. Keep pending buttons at or above 4.5:1.
Handling the "it's been too long" case
This is where most implementations give up. The request is still in flight at 8 seconds. The user has stared at a spinner for an eternity in UX time. What now?
A pattern we've shipped on several checkout flows:
const [pending, setPending] = useState(false);
const [slow, setSlow] = useState(false);
async function onSubmit() {
setPending(true);
const slowTimer = setTimeout(() => setSlow(true), 4000);
try {
await submitOrder();
} finally {
clearTimeout(slowTimer);
setPending(false);
setSlow(false);
}
}
When slow flips to true, surface a message near the button: "This is taking longer than usual. Don't refresh — we're still processing your order." That one sentence prevents the two worst user behaviors: refreshing the page (potential duplicate charge) and closing the tab.
Idempotency is a UX feature
If your backend doesn't handle duplicate submits safely, no amount of frontend disabling will save you. A disabled button only disables that button — users can hit Enter, use the browser back/forward buttons, or submit from another tab. Idempotency keys on the request are the real fix. The pending state is just the polite surface of a system that's actually safe.
Optimistic UI: when to skip the pending state entirely
For low-stakes, high-confidence actions — toggling a like, archiving an email, reordering a list — don't show a pending state at all. Update the UI immediately, send the request in the background, and only surface something if it fails.
The rules we follow:
- Reversible on the client without re-fetching.
- High success rate (> 99% in normal conditions).
- Low consequence if it fails and we have to roll back.
Payments, account deletions, and anything that touches money or identity do not qualify. Favorites, filters, and view preferences do. When the request fails, revert the UI and show a quiet toast — don't yank the user back into a modal.
Where we'd start
Audit your current button component. If it has a single loading prop and nothing else, you're treating a 200ms filter the same as a 10-second checkout. Split it:
- Add a
pendingLabelprop so each use site can supply the right verb. - Wrap the pending visual in the 180ms delay hook above.
- Add
aria-busyand verify with a screen reader — not just devtools. - For any button that triggers a request over ~3 seconds in the real world, add a "taking longer than usual" message hook.
- Confirm your backend handles idempotency on destructive and financial operations.
If you want help turning this into a design system component your whole team can rely on, that's exactly the kind of work we do on our design and engineering engagements. The spinner is the easy part. The honesty around it is the product.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Empty States Are the Feature You Shipped Without Designing
Empty states are the first screen most users actually see. Here's how to design them so they teach, convert, and don't make your product feel broken on day one.

Skeleton Screens vs Spinners vs Nothing: What Actually Feels Faster in 2026
Skeletons aren't a free upgrade over spinners, and sometimes showing nothing is the right call. Here's how to pick a loading pattern that matches the actual perceived latency.

Focus Rings Are the Accessibility Win You're Still Botching
Focus rings are the cheapest accessibility win in your codebase, and most teams still remove them for aesthetic reasons. Here's how to design and ship them properly in 2026.
