Frontend Architecture
Accessible forms: the keyboard path is the real spec
The checkout form passes QA. It works flawlessly — with a mouse. Then a screen-reader user files a ticket: they tab to the email field, hear nothing but “edit text,” fill the whole form blind, hit the slick <div> “Pay” button — and Enter does nothing, because a div is not a button. They submit by accident, three fields are wrong, and the red error text that “clearly” appears is never announced. The form did not crash. It is simply unusable for everyone who does not point and click. That is a WCAG 2.2 AA failure, and on 1,000,000 home pages it is the most common one there is.
The label is the input’s name, and a third of the web has none
Every input needs a programmatically associated label — not placeholder text, not visually adjacent text, an actual association the accessibility tree can read. The WebAIM Million 2025 report scanned the top one million home pages and found 34.2% of all form inputs were not properly labeled. With an average of 6.3 inputs per page, that is the single most common failure category on the web. A screen reader hitting an unlabeled field announces “edit text” and nothing else — the user has no idea what to type.
Two correct associations exist, and the difference matters:
- Explicit:
<label for="email">Email</label>plus<input id="email">. Thefor(in React,htmlFor) matches the input’sid. This is the robust default — the label can sit anywhere in the DOM. - Implicit: wrap the input in the label —
<label>Email <input></label>. No id needed, but the input must be a direct descendant, and some older AT and styling setups handle it less reliably.
Placeholders are not labels. They vanish on focus, fail color-contrast minimums, and several screen readers ignore them entirely. A “floating label” that animates a placeholder into a label is fine only if a real associated <label> exists underneath. This whole area is WCAG 1.3.1 Info and Relationships (Level A) plus 4.1.2 Name, Role, Value: the relationship between label and control must be exposed to assistive tech, not just visible on screen.
| Approach | Accessible name? | Verdict |
|---|---|---|
<label for=“x”> + <input id=“x”> | Yes — explicit | Default; label can live anywhere in the DOM |
<label>Email <input></label> | Yes — implicit | Works, but input must be a direct child |
placeholder=“Email” only | No | Vanishes on focus, low contrast, often ignored by AT |
aria-label=“Email” on input | Yes — but no visible label | Last resort (icon-only fields); sighted users get nothing |
Errors that nobody hears
You validate on submit, paint the invalid fields red, drop a message under each one. To a sighted mouse user, perfect. To a screen-reader user, silence — visually changing the DOM announces nothing. The fix is two-part, and both parts are easy to get subtly wrong.
First, announce the error. A message inserted into a container with role="alert" (or an aria-live="assertive" region) is read out the moment it appears — that is WCAG 4.1.3 Status Messages (Level AA): the user learns of a status change without moving focus to find it. The trap: the live region must already exist in the DOM before you write text into it. If you mount the <div role="alert"> and its message in the same render, many screen readers miss the change. Render the empty live region up front; only inject the text on error.
Second, associate the message with its field via aria-describedby pointing at the message’s id, and mark the field aria-invalid="true". Now when the user lands on that input, the screen reader reads label, value, “invalid,” and the error text together. That is WCAG 3.3.1 Error Identification (Level A). Go one better — say how to fix it (“enter a date as MM/DD/YYYY”), not just “invalid” — and you satisfy 3.3.3 Error Suggestion (Level AA).
Why this works
role="alert" is aria-live="assertive" plus aria-atomic="true" — it interrupts the screen reader to read the whole region immediately. Use it for errors. For non-urgent status (“Saved”, “3 results”) prefer role="status" (polite): it waits for a pause instead of barging in. Two assertive regions firing at once will stomp each other, so reserve assertive for things the user must hear now.
Focus is the difference between usable and not
Validation fails. A sighted user sees the red fields and clicks the first one. A keyboard or screen-reader user, on submit, is still focused on the submit button — at the bottom of the form. They have no idea anything failed, and even if the page scrolled, they would have to tab backward through the whole form hunting for the broken field. The fix is one line of intent: on submit error, move focus to the first invalid field (firstInvalid.focus()). The user is dropped exactly where the work is, and because that field carries aria-describedby + aria-invalid, the screen reader immediately reads what is wrong and how to fix it.
This is the behavior browsers give you for free with native constraint validation — required, type="email", pattern, min/max — where the browser focuses and scrolls to the first invalid control on submit and shows a built-in bubble. The moment you intercept with e.preventDefault() and roll your own validation (which most real apps do, for custom messages and async checks), you inherit that focus responsibility. Forget it, and you have built the classic “works with a mouse, dead with a keyboard” form.
Custom JS validation runs on submit and finds three invalid fields. What's the senior move for keyboard/AT users?
Custom widgets are where it silently dies
The deepest failures are not missing labels — they are reinvented controls. A designer wants a styled dropdown, so an engineer builds <div class="select"> with click handlers. It looks identical. To assistive tech it is invisible as a control: a div has no role, is not in the tab order, has no keyboard behavior. A screen reader reads it as plain text; a keyboard user cannot reach it, cannot open it, cannot pick an option. The same trap snares <div onClick> “buttons”: MDN’s button-role docs spell out that to make a div behave like a button you must add role="button", tabindex="0", a keydown handler for both Enter (keyCode 13) and Space (32) with preventDefault, plus the disabled and focus semantics you got free from <button>. People remember the click handler and forget every one of those.
The senior rule is blunt: use the native element. <button>, <input>, <select>, <a href> ship with role, focusability, keyboard handling, and OS-level form integration that you cannot fully replicate and will not maintain. Reach for ARIA-rebuilt widgets only when the native element genuinely cannot do the job (a true combobox with filtering, a multiselect) — and then follow the full APG keyboard pattern, because a half-built role="combobox" is worse than the plain <select> you replaced.
Design hands you a custom-styled dropdown that the native <select> can't match visually. Pick the implementation.
Order the steps to handle a failed submit accessibly:
- 1 Run validation on submit and collect the invalid fields
- 2 Set aria-invalid="true" on each invalid field
- 3 Write each error message into a pre-existing live region / element the field's aria-describedby points to
- 4 Move keyboard focus to the first invalid field (firstInvalid.focus())
- 5 Let the screen reader read label + invalid + error text now that focus is on the field
- 01A screen-reader user submits your form, three fields are invalid, and they hear nothing. Walk through what's missing and how to fix each gap.
- 02Why is a <div role="button"> dropdown a worse bug than a missing label, and what does 'use the native element' actually buy you?
Accessible forms are decided by the keyboard-and-screen-reader path, not the mouse path. Every input needs a programmatically associated label (explicit htmlFor/id or implicit wrapping) — placeholders don’t count, and 34.2% of the web’s form inputs fail this. Errors must be announced through a live region (role=“alert”/aria-live) that already exists in the DOM, associated to their fields with aria-invalid + aria-describedby, and stated as suggestions, not just “invalid” — covering WCAG 1.3.1, 3.3.1, 3.3.3, and 4.1.3. On a failed submit you must move focus to the first invalid field; native constraint validation does this for free, but the moment you preventDefault you own it. And the deepest trap is the reinvented control: a styled div is invisible to assistive tech, so reach for the native <button>/<select>/<input> first and only rebuild with the full ARIA pattern when the native element genuinely can’t do the job. Get this right and the form that “works with a mouse” finally works for everyone.