When to use
Almost always. Most fields you write will look like this — a real <label>, optional hint text, an <input> with the right type and autocomplete, and an error region that gets announced when something goes wrong. The pattern barely changes between text, email, password, number, and most other inputs.
The pattern
The four building blocks, all linked by id:
<label for="email">Email address</label>
<p id="email-hint">We’ll only use this to send you the receipt.</p>
<input
type="email"
id="email"
name="email"
required
aria-describedby="email-hint email-error"
autocomplete="email"
/>
<p id="email-error" role="alert"></p> What each piece is doing:
<label for="email">is the visible label and the accessible name. Clicking it focuses the input. Don’t skip this for a placeholder — placeholders disappear on type, fail contrast checks, and confuse autocomplete.type="email"triggers the right mobile keyboard, gives you free format validation via:invalidandcheckValidity(), and tells screen readers it’s an email field.autocomplete="email"lets the browser fill it. Skip this and people with disabilities who rely on autofill have to type their address every time.aria-describedby="email-hint email-error"ties both the hint and the error to the input. When the user focuses the field, the screen reader announces the label, then the description. Listing both IDs is fine — empty descriptions aren’t announced.requiredmarks the field required for both the browser and assistive tech. The screen reader announces “required” alongside the label.
Showing errors
Validate on blur (when the user leaves the field), not on every keystroke — otherwise you yell at people while they’re still typing. Once an error is showing, then re-validate on input so they get immediate feedback when they fix it.
const validate = () => {
let message = '';
if (!input.value.trim()) message = 'Enter your email address.';
else if (!input.checkValidity()) message = 'That doesn’t look like an email address.';
if (message) {
input.setAttribute('aria-invalid', 'true');
error.textContent = message;
return false;
}
input.removeAttribute('aria-invalid');
error.textContent = '';
return true;
};
input.addEventListener('blur', () => validate());
input.addEventListener('input', () => {
if (input.getAttribute('aria-invalid') === 'true') validate();
});
form.addEventListener('submit', (event) => {
if (!validate()) {
event.preventDefault();
input.focus(); // jump back to the broken field
}
}); Two things this gets right:
aria-invalid="true"is announced (“invalid entry”) and lets you style the input differently. Set it only when there’s an error; remove it when there isn’t.- The error element is part of
aria-describedbyfrom the start. When the text changes, screen readers re-announce the description on next focus. For immediate announcement on a submit-time error, give the error containerrole="alert"(oraria-live="assertive").
On submit
Don’t rely on the browser’s native error popovers — they look different in every browser, can’t be styled, and disappear quickly. Use novalidate on the form and run your own validation, but keep required, type="email", etc. on the inputs so checkValidity() still works for you.
When validation fails, send focus to the first invalid field. A list of errors at the top is also fine, but each error should be a link that focuses the corresponding input.
Anti-pattern: the placeholder-as-label
This shows up in almost every “modern” sign-up form. It looks clean. It fails three different SCs.
What’s wrong:
- Placeholder is not a label. It disappears on first keystroke (the user can no longer remember what the field was for), it usually fails contrast (placeholder text is grey-on-grey by default), and screen readers may or may not announce it as the accessible name depending on browser/AT combination. Use a real
<label>. - Error text is unrelated to the input. A floating
<span>next to the field looks visually associated, butaria-describedbyis what actually makes it part of the input’s announced description. <span>as a submit button — same four failures as the button anti-pattern: no focus, no role, no Enter/Space activation, no focus ring.- No
type="email"— users on touch keyboards get the wrong keyboard layout, and you lose:invalidstyling.
Common variations
- Required vs optional — mark optional fields with “(optional)” in the label rather than slapping a red asterisk on every required one. If almost every field is required, marking the optional ones is shorter and clearer.
- Password fields —
type="password"plusautocomplete="new-password"(sign-up) orcurrent-password"(login). Add a real “show password” toggle button (aria-pressedfor state). - Field groups (radio / checkbox) — wrap the group in a
<fieldset>with a<legend>. The<legend>becomes the group’s name; each radio/checkbox keeps its own<label>. - Async validation (e.g. “is this username taken?”) — debounce, then update the error region. Use
aria-busy="true"on the input while the request is in flight if it takes more than a moment. - Client + server errors — server-rendered errors should look identical to client-side ones. Same DOM, same
aria-invalid, samearia-describedbywiring.
Checklist
- Real
<label for="…">(or<label>wrapping the input) — never placeholder-as-label - Correct input
type(email, tel, url, number, password) -
autocompleteset to the right token from the HTML autofill list (opens in new tab) - Hint text linked via
aria-describedby - Error message linked via
aria-describedby(alongside the hint) -
aria-invalid="true"while invalid; removed when fixed - Validation runs on blur (then re-validates on input once errored)
- On submit, focus jumps to the first invalid field
-
requiredon truly required fields - Hit area at least 44×44 CSS pixels for touch