All articles
Design & UXOctober 25, 2026 6 min read

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:

  1. Did my click register? (within ~100ms)
  2. Is the system actually working on it? (within ~1s)
  3. 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 labelBad pending labelGood pending label
Save changesLoading…Saving…
Place orderPlease waitPlacing order…
Send inviteSubmittingSending…
Delete accountLoading…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-busy tells assistive tech the control is working. Use it alongside disabled, not instead of.
  • The spinner itself gets aria-hidden — it's decorative. The word "Saving…" carries the meaning.
  • Some teams prefer aria-disabled over the native disabled attribute 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" or aria-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:

  1. Add a pendingLabel prop so each use site can supply the right verb.
  2. Wrap the pending visual in the 180ms delay hook above.
  3. Add aria-busy and verify with a screen reader — not just devtools.
  4. For any button that triggers a request over ~3 seconds in the real world, add a "taking longer than usual" message hook.
  5. 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.

#UX#Accessibility#Frontend#Design Systems

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project