Greying out the submit button until the form is valid is the most common way to make a form feel broken: the button does nothing, explains nothing, cannot be focused with the keyboard, and gives screen-reader users no clue which of twenty fields is holding it back.

Two different jobs get merged into “disable the button”. One is gating — preventing a submit while the form is invalid. The other is guarding — preventing a second submit while the first is in flight. Submission state and optimistic updates models the lifecycle; this page shows how to implement both jobs without the native disabled attribute’s accessibility costs.


Context and prerequisites

What native disabled does to a button:

  • Removes it from the tab order, so keyboard users may not discover it exists.
  • Suppresses click events entirely — you cannot respond to a press with an explanation.
  • Is announced as “dimmed” or “unavailable” with no reason attached.
  • Commonly fails colour contrast, because browsers and design systems style disabled controls at low contrast by design (and WCAG exempts inactive components, which is not the same as making them usable).

For gating, the better pattern is: keep the button enabled, validate on press, and respond with the error summary and focus management. The user learns what is wrong at the moment they ask to submit. For guarding, keep the button focusable but ignore presses while submitting, and communicate the busy state with aria-disabled and visible text.

Native disabled, aria-disabled and always-enabled compared A natively disabled button is not focusable, receives no clicks, is announced as unavailable without a reason and is suitable only when the action is truly impossible and explained nearby. An aria-disabled button stays focusable, still receives clicks that your handler must ignore, is announced as unavailable, and suits guarding against double submit. An always-enabled button that validates on press is focusable, handles the click by showing the error summary, and suits gating on validity. Strategy Focusable Click Best for disabled attribute no swallowed truly impossible actions, explained nearby aria-disabled="true" yes fires; you ignore it in-flight guard enabled, validate on press yes shows the summary gating on validity

The core pattern: enabled for gating, aria-disabled for guarding

export function wireSubmit(
  form: HTMLFormElement,
  button: HTMLButtonElement,
  opts: {
    validate: () => Promise<{ ok: boolean; firstInvalid?: HTMLElement }>;
    send: (data: FormData) => Promise<void>;
    showSummary: () => void;
    status: HTMLElement;                  // a role="status" region
  },
) {
  let inFlight = false;
  const label = button.textContent ?? "Submit";

  form.addEventListener("submit", async (e) => {
    e.preventDefault();
    // Guard: a second press (or Enter in a field) during the request is a no-op.
    if (inFlight) return;

    // Gate: validate at the moment of asking, and explain on failure.
    const result = await opts.validate();
    if (!result.ok) {
      opts.showSummary();                 // moves focus to the summary
      return;
    }

    inFlight = true;
    // aria-disabled keeps the button focusable and in the accessibility tree;
    // the native attribute would drop focus if it is on the button right now.
    button.setAttribute("aria-disabled", "true");
    button.textContent = "Sending…";
    opts.status.textContent = "Sending your application";
    const data = new FormData(form);      // build BEFORE any fields change
    try {
      await opts.send(data);
      opts.status.textContent = "Application sent";
    } catch {
      opts.status.textContent = "Sending failed. You can try again.";
    } finally {
      inFlight = false;
      button.removeAttribute("aria-disabled");
      button.textContent = label;
    }
  });
}
/* Style the guarded state from the ARIA attribute, keeping contrast legible. */
button[aria-disabled="true"] { cursor: progress; opacity: 0.85; }

Step-by-step walkthrough

  1. Separate gating from guarding in code. Validity decides what happens on press; submission state decides whether a press is accepted at all.
  2. Keep the button enabled while the form is invalid. On press, run validation, show the error summary and move focus to it — the pattern in building an accessible error summary.
  3. Guard in the submit handler, not only on the button. Pressing Enter in a text field submits the form without touching the button; the inFlight check in the handler covers both paths.
  4. Mark the guarded state with aria-disabled and visible text. Focus stays where it is, and “Sending…” tells sighted users the press registered.
  5. Announce progress through a status region. The polite role="status" message confirms the action for screen-reader users without stealing focus.
  6. Pair the client guard with a server idempotency key. A client guard stops double presses; it cannot stop retries by the network layer or a second tab. See handling double submit and idempotency.
What a keyboard user experiences The user tabs to the submit button, which is focusable because it is never natively disabled. Pressing it runs validation and moves focus to the error summary, which lists two problems. The user follows the summary links, fixes both fields and returns to the button. Pressing it again starts the request; the button reads Sending and a status region announces sending. When the request completes, the status announces that the application was sent. Tab to Submit Button is focusable. With native disabled it would be skipped and never found. Enter Validation fails Focus moves to the error summary. "There are 2 problems" — each a link to its field. fix, return, Enter Submitting aria-disabled, text: Sending… Status region: "Sending your application". Further presses ignored. response Done Button restored. Status region: "Application sent".

Failure modes and edge cases

1. Disabling the focused button

Setting disabled on the button the user just pressed removes focus from it; in several browsers focus falls back to <body>, and a screen-reader user loses their place. aria-disabled avoids this entirely.

2. Tooltips that explain a disabled button

“Complete all required fields” in a tooltip on a natively disabled button is unreachable for keyboard users (it cannot be focused) and for touch users (there is no hover). If you must keep a button unavailable, put the explanation in visible text next to it.

3. Disabling the whole form during submit

Disabling every field stops edits in flight but also removes them from any FormData built afterwards and from the accessibility tree’s operable set. Build the payload first, and prefer keeping fields enabled — resetting the dirty baseline after a successful save shows how to handle edits made during a request.

4. Spinners with no text

A spinner that replaces the button label leaves the button with no accessible name. Keep text (“Sending…”) in the button, or give it aria-label, and let the spinner be decorative with aria-hidden.

5. Enabled-when-valid still has its place

For a single-field form such as a search box or a one-question poll, disabling until something is entered is low-risk and familiar. The problems grow with the number of fields that could be responsible; beyond one or two, validate on press.

Two jobs, two mechanisms Gating asks whether the form is ready to submit. It is implemented by keeping the button enabled, validating when pressed, and responding with the error summary and focus. Guarding asks whether a submission is already in progress. It is implemented with an in-flight flag in the submit handler, aria-disabled on the button, visible sending text and a server idempotency key. Gating (is it ready?) Button stays enabled. Validate on press. Respond with the summary and focus. Guarding (is it already going?) inFlight flag in the submit handler. aria-disabled plus "Sending…". Idempotency key on the server.

Verification checklist


Frequently Asked Questions

Does aria-disabled actually stop the button from working?

No. It only communicates state to assistive technology; clicks and Enter presses still fire. That is why the submit handler checks the in-flight flag itself. The benefit is that the button remains focusable and discoverable while you decide what a press does.

Is a disabled button a WCAG failure?

Not by itself: WCAG exempts inactive components from contrast requirements. The problems are usability ones — undiscoverable by keyboard, no reason given — which is why the guidance from accessibility practitioners is to avoid disabling submit buttons in multi-field forms rather than to treat it as a strict violation.

What about forms where the server decides validity?

Then validating on press is the only honest option: the client cannot know whether the form is “valid” until it asks. Submit, show “Checking…”, and render the server’s field errors as described in mapping 422 responses to field errors.


Related

← Submission State and Optimistic Updates