Skip to content

Button uses native disabled for busy state, which drops focus #4871

Description

@cixzhang

What happens

While a clickAction is pending, Button sets the native disabled attribute on the <button>. A natively disabled element cannot hold focus, so the browser moves focus to <body> the moment the action starts, and it is not restored when the action settles.

In packages/core/src/Button/Button.tsx:

const isLoadingState = isLoading || isPending;
const buttonDisabled =
  isDisabled || groupDisabled || (isLoadingState && !isInterruptible);
...
disabled={useAriaDisabled ? undefined : buttonDisabled}

useAriaDisabled is only true when a tooltip is set, so in the common case the busy button is natively disabled.

Why it's a problem

A keyboard user who activates "Save" with Enter loses their place: focus falls to <body>, so the next Tab restarts from the top of the document, and screen readers announce nothing when the action completes. This is the classic reason busy state should never be expressed as disabled.

It also contradicts the documented rule in the API Conventions wiki page ("Disabled vs Busy"), which says busy is visual-only — aria-busy plus a spinner — and that only isDisabled uses the native attribute. Input components already follow that rule: they render aria-busy and keep the control focusable, guarding re-entry in the change handler.

Why the native disable looks redundant

Button already guards re-entry in the click handler with a ref:

const actionInFlightRef = useRef(false);
if (buttonDisabled || (actionInFlightRef.current && !isInterruptible)) {
  e.preventDefault();
  return;
}

That guard is what actually makes a fire-once action fire once — it dedupes even a same-tick double click, which disabled does not reliably do. The native attribute adds the focus loss without adding the safety.

Suggested fix

While busy (and not otherwise disabled):

  • keep the button focusable — no native disabled
  • keep aria-busy="true" and the spinner
  • set aria-disabled="true" so AT announces the state
  • suppress Enter/Space activation via the existing handler path, the way the aria-disabled + tooltip branch already does
  • leave isDisabled behavior unchanged: that stays native disabled

isInterruptible can stay as-is; it would simply become the variant that also lets activation through.

Notes

Changing this affects every consumer that relies on a busy button being unclickable, so the re-entry guard needs to cover the keyboard path too before the attribute comes off. Worth a changeset and a note in the release notes.

Activity

  1. gonzoblasco commented on Aug 22, 2026

    @gonzoblasco
    Contributor

    Both #4885 and #4879 close the focus-loss correctly (busy no longer uses the native disabled), but they land the fix at different depths, and I think the difference matters for the href case and for the announcement story. I went through both diffs against the current Button.tsx so these are code-checked, not theoretical.

    What both do right. A busy button stays focusable, aria-busy + aria-disabled are set, isDisabled keeps native disabled. That's the core of #4871 and neither regresses it.

    Where they diverge - the href (anchor) case.

    • fix(Button): use aria-disabled instead of native disabled while busy-only #4885: renderAsLink = href != null && !buttonDisabled with buttonDisabled including the busy-only flag means a busy anchor with href flips to a <button> for the duration of the action. It does stay focusable (via the aria-disabled path), so the focus drop is fixed - but the element still changes mid-action. Any consumer styling or testing against the anchor tag sees it become a <button> and back. That's a behavioral surface change that exists only because the busy state made buttonDisabled true.
    • fix(core): keep busy Button focusable, recover from failed clickAction #4879: keeps the busy anchor an anchor, and documents it ("With href, a busy button now stays an anchor instead of swapping to a disabled <button> mid-action"). That preserves element identity, which I'd argue is the more correct fix - busy should only change the announced/interaction state, not the element type.

    So for the href case specifically, #4879 is the more complete fix. #4885 is smaller and fixes the focus loss, but leaves the element-flip in place.

    One thing I'd want verified in whichever lands - the keyboard re-entry guard.

    Both rely on the existing actionInFlightRef/handler guard to keep Enter/Space from re-firing while busy, since disabled no longer blocks it. #4879 tests this explicitly ("Enter/Space on the focused busy button must not re-fire"); I'd want that same explicit test on the merged result, because the guard is now the only thing preventing double-submit - the native attribute was a silent backstop that's gone.

    On screen-reader behaviour: aria-busy + aria-disabled on a focused control is the right pair, but it's worth one real pass with NVDA before merge. In my experience NVDA announces the aria-busy state change more reliably than a focused element being "disabled" - and since the button stays focused, the SR should announce "busy" when the spinner appears and clear it when it settles. That live announcement is the part that a jsdom test can't assert, and it's the actual payoff for the user who lost their place today.

    Either direction is a real improvement over the native-disabled drop; I'd push for #4879's element-identity handling on href plus the explicit keyboard-guard test.

  2. added a commit that references this issue on Aug 31, 2026
    7333d1e
  3. added a commit that references this issue on Sep 10, 2026
    91cb622
  4. added 4 commits that reference this issue on Sep 21, 2026
    ece8acb
    d2b60a7
    78c2a6d
    f913bfe
  5. added a commit that references this issue on Oct 10, 2026
    8383187
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions