Patterns Form field with validation

Form field with validation

A label, an input, a hint, an error message — wired together so screen readers announce them all when focus lands on the field.

Reviewed against the methodology checklist Updated

Try the keyboard

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

Accessible form field

We’ll only use this to send you the receipt.

The four building blocks, all linked by id:

HTML
<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:

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.

JS
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:

  1. 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.
  2. The error element is part of aria-describedby from 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 container role="alert" (or aria-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.

Don’t do this
Continue

What’s wrong:

  1. 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>.
  2. Error text is unrelated to the input. A floating <span> next to the field looks visually associated, but aria-describedby is what actually makes it part of the input’s announced description.
  3. <span> as a submit button — same four failures as the button anti-pattern: no focus, no role, no Enter/Space activation, no focus ring.
  4. No type="email" — users on touch keyboards get the wrong keyboard layout, and you lose :invalid styling.

Common variations

Checklist

Screen reader transcript

Voiced by George — reading what VoiceOver on macOS Safari announces. Other readers (NVDA, JAWS) phrase things differently; the meaning is what matters.

Screen reader announcements for each step
ListenYou doScreen reader says
Tab onto the fieldEmail address, edit text. We’ll only use this to send you the receipt. Required, invalid entry.
Type a valid value, then Tab off(no error announced; aria-invalid removed)
Tab off while emptyEnter your email address. (live error read aloud)

Screen reader transcript — anti-pattern

Same interaction, but on the broken version above. Notice what the user is missing.

Screen reader announcements for each step
ListenYou doScreen reader says
Tab onto the fieldEmail address, edit text. (the placeholder is the only "label" — disappears on type)
Submit while empty(silence — the error span is unrelated to the input)
Tab to the "submit"Continue. (no role — sounds like static text)

WCAG references