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.
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
- Separate gating from guarding in code. Validity decides what happens on press; submission state decides whether a press is accepted at all.
- 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.
- Guard in the submit handler, not only on the button. Pressing Enter in a text field submits the form without touching the button; the
inFlightcheck in the handler covers both paths. - Mark the guarded state with
aria-disabledand visible text. Focus stays where it is, and “Sending…” tells sighted users the press registered. - Announce progress through a status region. The polite
role="status"message confirms the action for screen-reader users without stealing focus. - 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.
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.
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.