The exact problem: a reader presses submit, the button is replaced by a spinner, the request takes two seconds, and focus is on the document body — so when the response arrives, nothing is announced and the next Tab starts from the top of the page.
Context and Prerequisites
Where focus goes after a validation failure is covered in focus management after validation. This page is about the gap the async submit opens: the period between pressing submit and the response, during which the element that had focus can disappear.
The Rule: Never Let the Focused Element Vanish
async function onSubmit(e: SubmitEvent, form: HTMLFormElement): Promise<void> {
e.preventDefault();
const submitter = (e.submitter as HTMLButtonElement | null) ?? form.querySelector('[type=submit]');
// Disable, do NOT remove or replace. A disabled button keeps its place in the
// DOM and keeps focus; replacing it with a spinner drops focus to <body>.
if (submitter) {
submitter.disabled = true;
submitter.setAttribute('aria-busy', 'true');
}
announce('Submitting…', 'polite');
try {
const res = await send(new FormData(form, submitter ?? undefined));
if (res.ok) {
// Success moves focus deliberately, to the confirmation heading, which is
// where the reader's next task begins.
focusConfirmation();
return;
}
const errors = await mapServerErrors(res);
renderErrors(errors);
// Failure: two or more errors go to the summary; one goes to its field.
Object.keys(errors).length > 1 ? focusSummary() : focusField(Object.keys(errors)[0]);
} finally {
if (submitter) {
submitter.disabled = false;
submitter.removeAttribute('aria-busy');
}
}
}
The single most effective rule is in the first branch: disable, do not replace. A disabled button remains in the document and, in current browsers, retains focus — so when the response arrives the reader is still somewhere sensible, and a focus move to the summary or the confirmation is a move from a known place rather than from nowhere.
Step-by-Step Walkthrough
-
Disable, do not replace. The busy state is an attribute, not a different element.
-
Announce the wait politely. “Submitting…” in the status region, so a reader who cannot see the spinner knows one exists.
-
Record the focused element anyway. Cheap insurance if a re-render replaces it despite your intentions.
-
Move focus deliberately on the response. Confirmation heading on success; summary or field on failure.
-
Re-enable in a
finally. A thrown error that leaves the button disabled is a form the reader cannot retry. -
Do not move focus for a background save. An autosave completing is not a reason to take the reader’s cursor.
Failure Modes and Edge Cases
1. A framework re-render replaces the button anyway
Keying the button on the submitting state remounts it. Keep the key stable and change only the attributes.
2. Focus moved before the target exists
Rendering the summary and focusing it in the same frame works only if the render is synchronous. Await the render, then focus — and check the element is in the document.
3. The reader moved during the request
Someone who tabbed away and started typing elsewhere should not be yanked back for a success. For a failure, moving focus is still right — they need to fix something — but announce it rather than moving silently.
4. Navigating away on success
If success means a route change, the destination must take focus. A client-side navigation does not move focus by itself, so the reader lands on a new page with focus still on the old one.
5. Double submission via keyboard repeat
Holding Enter on a focused submit button repeats the keydown. The disabled attribute covers it, which is another reason to disable rather than replace.
The busy state has an accessible shape as well as a visual one, and the two are easy to get out of step:
Verification Checklist
Common Pitfalls
- Keying the submit button on the submitting state. A framework that sees a different key remounts the element, which is the same failure as replacing it by hand. Keep the key stable and change only the attributes.
- Focusing the summary in the same tick it is created. The element has to be in the document before
focus()can reach it. Insert, then focus, and check the node is connected before calling. - Leaving the button disabled after a thrown error. A
catchthat does not re-enable produces a form the reader cannot retry, which is worse than the original failure. Re-enable in afinally. - Announcing the result only visually. A spinner that stops and a message that appears are both invisible to a screen reader unless something announces them. The focus move covers the summary; the polite region covers the wait.
- Moving focus for a background save. An autosave completing is not a reason to take the cursor away from whatever the reader is doing. Announce it politely and leave focus alone.
Related
- Focus Management After Validation — where focus goes once the response is known
- Moving Focus to the First Invalid Field — the single-error branch
- Retrying Failed Submissions with Backoff — keeping the button usable across retries
← Focus Management After Validation
Frequently Asked Questions
Why does replacing the button with a spinner break focus?
Because focus lives on an element, and removing that element from the document leaves the browser with nowhere to put it — so it falls back to the body. From there nothing is announced when the response arrives, and the next Tab starts from the top of the page. Disabling the button instead keeps the element, keeps focus, and gives you a known place to move focus from when the response comes back.
Should focus move if the reader has already moved on?
For a failure, yes — they need to repair something, and leaving them where they are means the failure may never be noticed. Announce it as well, so the move is explained rather than surprising. For a success, no: taking the cursor away from whatever they started doing is disruptive and gains nothing, so announce it politely and leave focus alone.
What about a form that navigates on success?
The destination has to take focus, because a client-side navigation does not move it. Without that, the reader arrives on a confirmation page with focus still on the button of a form that no longer exists, and a screen reader announces nothing about where they are. Focus the destination’s main heading as part of the navigation, exactly as a multi-step wizard focuses each new step.