We audited two forms at Onneta — our login page and our onboarding flow. Different pages, different purposes, different codebases. Both had the exact same accessibility gaps. That's not a coincidence. It's a pattern that lives in most web forms.
The Problem With Async Submit Buttons
When a user clicks "Sign in" or "Create account", the form makes an API call. During that call, the button text changes to "Signing in..." and gets disabled. Visually, this works. A sighted user sees the loading state.
But a screen reader user hears nothing. The button was announced as "Sign in" and now it's... silent. The page might be loading. It might have failed. There's no way to know without aria-busy.
The Fix: aria-busy on Async Buttons
The solution is one attribute and four lines of code:
// When submission starts
submitBtn.setAttribute('aria-busy', 'true');
// When submission ends (success or failure)
submitBtn.removeAttribute('aria-busy');
aria-busy="true" tells assistive technology that the element's content is being updated. Screen readers can announce "busy" or suppress intermediate announcements until the operation completes. When the attribute is removed, the final state is announced cleanly.
This needs to happen in every code path that ends the async operation — success, validation error, network failure, timeout. Miss one path and aria-busy stays stuck on, which is worse than not having it at all.
The Problem With Inline Validation
Our forms validate fields on submit and show error messages. The error text appears next to the field, connected via aria-describedby. Screen readers announce the error. Good so far.
But the input itself doesn't change state. The field has a red border (visual), an error message (text), and... no programmatic invalid state. A screen reader user tabbing through the form hears the field name but not that it failed validation.
The Fix: aria-invalid on Validated Fields
// When validation fails
emailInput.setAttribute('aria-invalid', 'true');
// When the user corrects the value
emailInput.removeAttribute('aria-invalid');
aria-invalid="true" marks the field as containing an error. Screen readers announce this alongside the field label, so users know immediately which fields need attention. Combined with aria-describedby pointing at the error message, this gives a complete picture: "Email, invalid, please enter a valid email address."
The Multi-Step Form Trap
Our onboarding flow has multiple steps. When a user clicks "Back" to return to a previous step, the old error messages and aria-invalid states from their earlier attempt were still visible. They'd see phantom errors from a form state that no longer existed.
The fix: every back-button handler must clear error state on the destination step:
function clearStepErrors(step) {
const container = document.getElementById(step);
// Clear visible error text
container.querySelectorAll('.error-msg')
.forEach(el => el.textContent = '');
// Clear aria-invalid on all inputs
container.querySelectorAll('[aria-invalid]')
.forEach(el => el.removeAttribute('aria-invalid'));
}
This isn't optional. Any form with back navigation that doesn't clear error state will confuse both sighted users (stale red borders) and screen reader users (stale aria-invalid announcements).
Why This Keeps Happening
We found these identical issues in two independent forms. The reason is straightforward: most form tutorials don't teach ARIA states. They cover labels, placeholders, required fields. They don't cover what happens during and after async operations.
The result is forms that work perfectly for sighted mouse users and fail silently for everyone else. Not because developers don't care — because the gap isn't visible during manual testing.
The Checklist
After patching both forms, we now audit every form page with these checks:
- Does the submit button set
aria-busy="true"during async operations? Check every code path — success, error, timeout. - Does every validated field set
aria-invalid="true"on failure? And clear it when the user corrects the value or the form resets. - Does back navigation clear error state? Both visible error text and
aria-invalidattributes on the destination step. - Does
aria-describedbylink each field to its error message? The error element must exist in the DOM even when empty.
Four checks. Takes two minutes with grep. Catches the same class of bug every time.
The Numbers
Across our login page and onboarding form, we applied 13 ARIA patches in two cycles. Every patch was a single-file change — no server restart, no deployment pipeline, no risk. The pattern repeats on every form: audit with grep, write the spec, execute the patch, verify with curl.
If you're building forms, run these four checks on yours. The gaps are almost certainly there. They've been there since the form was first written. And they're trivial to fix.