Form Accessibility: Labels, Errors and Focus Management
A practitioner’s guide to form accessibility: real label markup, error association with aria-describedby, focus management after failed submits, and testing.
Run a keyboard-only pass on the next contact form you ship. Tab to the email field, type nonsense, submit, and watch where focus goes. On most sites it goes nowhere: the page scrolls to the top, a red box appears somewhere below, and the browser’s focus ring is still sitting on the submit button that just reset itself. A screen reader user gets silence. A sighted keyboard user gets a scroll position with no explanation.
That single failure, focus after a failed submit, accounts for more real-world damage than every missing alt attribute on the same page. Form accessibility is mostly four things done properly, and the rest is detail: labels, grouping, error association, focus.
The frustrating part is that accessible forms are cheap to build and expensive to retrofit. Native HTML gets you about eighty percent of the way before you write a line of JavaScript. What follows is the version we actually use on production work, including the parts that break in Safari and the advice that doesn’t apply.
- Every control needs a programmatic label.
<label for>beatsaria-labelbecause it also gives you a bigger click target and survives translation tools. - Associate errors with
aria-describedbyplusaria-invalid="true", and keep the error element’s ID referenced at all times, even when it’s empty. A dangling reference is ignored, so you avoid rewriting the attribute on every validation pass. - After a failed submit, move focus to an error summary container with
tabindex="-1", or to the first invalid field. Never leave focus on the submit button. - Validate on blur only after a field has been touched, then re-validate on
inputonce an error is showing. Validating every keystroke from the first character is hostile, especially with a screen reader attached. - Automated tools like axe catch missing labels and contrast, but they cannot tell you whether your error message was announced. Keyboard plus NVDA or VoiceOver is the only test that settles it.
Labels are not optional, and placeholders are not labels
A control gets its accessible name from a specific order of precedence: aria-labelledby, then aria-label, then the associated <label>, then the placeholder as a last resort, then title. That last-resort behaviour is why placeholder-only forms pass some automated checks and still fail users. The name disappears the moment someone types, voice control users can’t reliably target it, and the default grey fails contrast in nearly every design system we’ve inherited.
Use the explicit pattern. It’s boring and it works:
<div class="field">
<label for="billing-postcode">Postcode</label>
<p id="billing-postcode-hint" class="field-hint">UK format, e.g. SW1A 1AA</p>
<input
type="text"
id="billing-postcode"
name="postcode"
autocomplete="postal-code"
inputmode="text"
aria-describedby="billing-postcode-hint billing-postcode-error"
required>
<!-- Rendered empty on load. A describedby reference to an empty
or missing element is harmlessly ignored by AT. -->
<p id="billing-postcode-error" class="field-error"></p>
</div>
Two details earn their keep here. The autocomplete token is a WCAG 2.1 requirement (1.3.5 Identify Input Purpose) and it’s also the thing that makes browser autofill work properly, which cuts abandonment on checkout forms more than any copy change you’ll write. And inputmode controls the mobile keyboard without lying about the field type. That matters because type="number" on a postcode or phone field is a genuine bug: it strips leading zeros, adds spinner controls, and rejects spaces.
Floating labels, honestly
Floating label patterns are fine if the label is a real <label> that moves with CSS transforms and never drops below roughly 12px at rest. They break in two common ways: someone implements them with a placeholder and a :placeholder-shown hack that leaves no label in the accessibility tree, or the shrunken label ends up at 10px grey on white. We stopped using them on form-heavy work. Fixed labels above the field read faster, survive long translated strings, and give you somewhere to put hint text. If your designer insists on floating labels, make the floated state at least 12px and check contrast in both states.

Grouping: fieldset, legend and the radio problem
A single radio or checkbox labelled “Yes” tells a screen reader user nothing. The group needs a name, and <fieldset> with <legend> is still the only mechanism that works everywhere without caveats.
<fieldset>
<legend>How should we contact you?</legend>
<div class="radio">
<input type="radio" id="c-email" name="contact" value="email" checked>
<label for="c-email">Email</label>
</div>
<div class="radio">
<input type="radio" id="c-phone" name="contact" value="phone">
<label for="c-phone">Phone</label>
</div>
</fieldset>
The alternative, role="radiogroup" with aria-labelledby, is valid and sometimes necessary when your layout grid fights fieldset‘s default rendering. It carries a cost: you then own roving tabindex and arrow key handling yourself, because you’ve left the native control behind. Pick the native version unless you have a specific reason not to. Styling a fieldset is a solved problem (border: 0; padding: 0; min-inline-size: 0; clears the browser defaults), and we went through the layout traps in detail in Forms in HTML Templates: Styling Without Breaking Accessibility.
One rule that keeps getting broken: don’t wrap your entire form in a single fieldset with a legend that duplicates the page heading. Screen readers announce the legend before every field inside it. Ten fields means ten repetitions of “Contact form, Contact form, Contact form”. Group only what genuinely belongs together.
Error messages screen readers actually announce
Most implementations get the markup right and the timing wrong.
The association itself is simple. Set aria-invalid="true" on the control, write the message into the element already referenced by aria-describedby, and make the message useful. “Invalid” is not a message. “Enter an email address in the format [email protected]” is.
function setFieldError(input, message) {
const errorEl = document.getElementById(${input.id}-error);
errorEl.textContent = message; // text only, never innerHTML
input.setAttribute('aria-invalid', 'true');
}
function clearFieldError(input) {
document.getElementById(${input.id}-error).textContent = '';
input.removeAttribute('aria-invalid'); // remove, don't set to "false"
}
Removing the attribute rather than setting aria-invalid="false" is a small thing, but a few older AT and browser combinations still announce the presence of the attribute oddly. Removal is unambiguous.
Now the timing. If a field already has focus and you inject an error into its described-by element, most screen readers will not announce the change, because the description was computed when focus arrived. That’s why inline-only error display fails silently for users who never move focus. Two fixes: move focus to the field after writing the error (which recomputes the description), or surface the errors through a summary that receives focus. The summary approach scales better, and it’s what we reach for on anything over three fields.
For asynchronous status, use a persistent polite live region that exists in the DOM from page load.
<!-- Must be in the DOM before you write to it. Inserting a live
region and its content in the same frame is unreliable. -->
<p id="form-status" role="status" aria-live="polite" class="visually-hidden"></p>
Write “Sending your message” on submit and “Message sent, we’ll reply within one working day” on success. Keep role="alert" for genuinely interruptive failures, like a network error, because it’s assertive and it cuts off whatever the user was listening to. Overusing assertive announcements is worse than none.
Focus management: where the cursor goes after submit
The error summary pattern, popularised by the GOV.UK Design System and still the most reliable thing in this space, works like this: on a failed submit, render a list of errors at the top of the form, each one a link to the offending field, then move focus to the summary container.
<div class="error-summary" id="error-summary" tabindex="-1" role="alert" hidden>
<h2>There is a problem</h2>
<ul id="error-summary-list"></ul>
</div>
const form = document.querySelector('#enquiry');
// Only disable native bubbles when JS is running, so no-JS users
// still get browser validation.
form.setAttribute('novalidate', '');
form.addEventListener('submit', (event) => {
const invalid = [...form.elements]
.filter(el => el.willValidate && !el.checkValidity());
if (invalid.length === 0) return;
event.preventDefault();
const summary = document.getElementById('error-summary');
const list = document.getElementById('error-summary-list');
list.innerHTML = '';
invalid.forEach(input => {
const message = messageFor(input); // your own copy, not validationMessage
setFieldError(input, message);
const li = document.createElement('li');
const link = document.createElement('a');
link.href = #${input.id};
link.textContent = message;
li.append(link);
list.append(li);
});
summary.hidden = false;
summary.focus(); // tabindex="-1" makes this programmatically focusable
});
Use your own copy rather than input.validationMessage. The native strings are localised, which sounds good until you realise Chrome says “Please fill out this field” and Safari says something else entirely, neither matching your tone, and neither telling the user which field it meant when read out of context in a list.
Two CSS lines save you from the classic complaints:
/ Sticky header clearance when focus jumps to a field /
input, select, textarea, .error-summary { scroll-margin-top: 6rem; }
/ Keep a visible ring on the programmatically focused summary /
.error-summary:focus { outline: 3px solid #b00; outline-offset: 2px; }
Safari and VoiceOver need the focused container to have readable content at the moment focus lands, so unhide the summary before you call focus(), not after. We’ve debugged that exact ordering bug three times on different projects.
When not to move focus
Don’t move focus during typing. Don’t auto-advance between one-character OTP boxes without also handling backspace, paste of the full code, and arrow keys, because the naive implementation traps people who make a typo. And if your form lives inside a modal, focus on open goes to the dialog or its first heading, not the first input: dropping focus straight into a text field makes screen reader users miss the dialog’s purpose entirely.
Validation timing and the ARIA form attributes worth using
Our rule, refined over a lot of client feedback: never validate a field the user has not yet left. On blur, validate once and mark it touched. From then on, validate on input so the error clears the instant they fix it. The asymmetry is the point. Errors appear late and disappear early.
On the ARIA side, the useful set is small: aria-describedby for hints and errors, aria-invalid for state, and aria-required only if you cannot use the native required attribute, which is rare. aria-errormessage exists and is technically the semantically correct attribute, but support across screen readers was still patchy enough in 2025 that we keep errors in aria-describedby instead. Check again in a year, but today the describedby route is the one that announces reliably across NVDA, JAWS and VoiceOver.
Things to avoid: aria-live on a container that also gets structurally rebuilt (you’ll get duplicate or truncated announcements), role="form" on a <form> element (redundant), and aria-label on a form landmark that just says “Form”.
The edge cases that break in production
- Custom selects. A
<div>withrole="combobox"needsaria-expanded,aria-controls,aria-activedescendantor managed focus, plus full arrow, Home, End, Escape and type-ahead handling. If you only need a styled dropdown, style the native<select>. The CSS for it got genuinely usable withappearance: base-selectlanding in Chromium, and native beats a half-finished combobox every time. - Date inputs. Three separate text inputs for day, month and year inside a fieldset outperform most custom calendar widgets for date of birth. Keep the picker for booking ranges where visual calendar context matters.
- File uploads. Hiding the native input with
display: noneremoves it from the tab order. Use a visually-hidden class that keeps it focusable, and show the selected filename in text, not just as an icon state. - Honeypots. A hidden field that bots fill in should be hidden with
aria-hidden="true",tabindex="-1"andautocomplete="off", or password managers will populate it and you’ll silently reject real submissions. We’ve seen this kill a lead form for six weeks before anyone noticed. - Success states. Replacing the form with a success message and no focus move leaves the user’s focus pointing at a removed node, which sends it to
<body>. Move focus to the success heading. The same thinking applies to every dead end in your UI, which we covered in Designing for Failure. - Static sites. If there’s no backend, the submit handler still needs to produce real status messaging rather than a redirect to a third-party thank-you page. Endpoints like WebForms return JSON you can act on, which is what lets you keep the user on the page, announce the result in your live region, and manage focus properly.
Testing without guessing
Automated scanning is a filter, not a verdict. Run axe DevTools or Lighthouse to catch missing labels, duplicate IDs and contrast failures in seconds, then stop trusting it. No automated tool can tell you whether your error message was spoken, whether focus landed somewhere sensible, or whether “Invalid input” means anything to a human.
The manual pass takes about ten minutes per form:
- Unplug the mouse. Tab through the whole form. Every control reachable, visible focus ring on each, logical order.
- Submit empty. Does focus move? Is the summary announced? Do the summary links jump to the right fields?
- Fix one error. Does it clear without a reload, and does the summary update?
- Turn on NVDA (Windows, free) or VoiceOver (Cmd+F5 on macOS) and repeat steps 2 and 3 with your eyes closed. This is the step people skip and the one that finds the bugs.
- Zoom the browser to 400 percent at 1280px wide. Labels should stay attached to their fields, and nothing should require horizontal scrolling.
Test on a real screen reader pairing if you can: NVDA with Firefox or Chrome, VoiceOver with Safari. Mixing VoiceOver with Chrome produces behaviour that no real user experiences and will send you chasing phantom bugs.
Frequently Asked Questions
Is aria-label ever acceptable instead of a visible label?
Yes, in two situations: a search input next to an obvious search button, and controls inside a dense table row where a visible label would be repeated dozens of times. Everywhere else a visible label is better, because it helps users with cognitive load and memory far more than it helps screen reader users, and it gives you a larger hit target. If you do use aria-label, make sure the text starts with the same words as any visible text, so voice control commands still match.
Should I use aria-errormessage instead of aria-describedby?
Not yet as your only mechanism. aria-errormessage is the semantically correct attribute and pairs with aria-invalid, but screen reader support has been inconsistent enough that errors can go unannounced. Keep errors in aria-describedby today. If you want to be forward-looking, you can set both, as long as the describedby reference is the one carrying the text.
How do I handle errors in a multi-step form?
Validate each step before advancing, show the error summary for that step only, and move focus to the summary exactly as you would on a single-page form. On step change, move focus to the new step’s heading with tabindex="-1" and announce progress (“Step 2 of 4, Delivery details”) in your polite live region. Never let a user reach the final step and then discover a failure from step one.
Does red text on an error message meet contrast requirements?
Often not. The common #dc3545 Bootstrap danger red gives roughly 3.9:1 on white, below the 4.5:1 needed for body text. Darken it to around #b02a37 or lower, and remember that colour alone can never be the only indicator: pair it with text and an icon so the error survives a greyscale screenshot.
Do CAPTCHAs break form accessibility?
Image CAPTCHAs do, badly, and audio fallbacks are barely better. Invisible or score-based challenges (reCAPTCHA v3, Cloudflare Turnstile) are far less harmful because most users never interact with them, though they can still fail silently for people on VPNs or older browsers. Start with a properly hidden honeypot plus a server-side rate limit, and add a challenge only if real spam volume justifies it.
If you only change one thing this week, make it focus after a failed submit. It takes about twenty lines of JavaScript, it works without any ARIA at all, and it fixes the single most common way accessible forms fall apart for keyboard and screen reader users. Then go back and check your labels. Everything else on this page is refinement on top of those two.


