Skip to content

Accessible Forms and Error Messages

intermediate18 min read

Forms Are Where Accessibility Fails

Here's a stat that should bother you: forms are the single biggest source of accessibility failures on the web. Not images, not navigation, not color contrast -- forms. And it makes sense when you think about it. A form is an interactive contract between the user and the application. If any part of that contract is invisible, confusing, or impossible to operate without a mouse, you've locked real people out.

The frustrating part? Most form accessibility issues are trivially easy to fix. A missing for attribute. A required field with no programmatic indicator. An error message that appears visually but is invisible to assistive technology. These aren't hard engineering problems -- they're awareness problems.

Mental Model

Think of a form like a conversation between two people, but one person can't see the other's face or body language. Everything the sighted user picks up from visual cues -- which field is which, what's required, what went wrong -- must be communicated explicitly through code. If your form relies on "you can see the red border" or "the label is obviously next to the input," you've broken the conversation for anyone not using their eyes.

Label Association: The Foundation

Every form input needs a programmatically associated label. Not just a visual label sitting nearby -- an actual connection that assistive technology can discover. There are two ways to do this, and both work.

Method 1: Explicit Association with for and id

The for attribute on a label points to the id on the input. This creates a programmatic link:

<label for="email">Email address</label>
<input type="email" id="email" name="email" />

When a screen reader focuses this input, it announces "Email address, edit text" -- the user knows exactly what they're typing into. Bonus: clicking the label also focuses the input, which is a nice usability win for everyone (especially on mobile where tap targets matter).

Method 2: Wrapping (Implicit Association)

You can wrap the input inside the label element:

<label>
  Email address
  <input type="email" name="email" />
</label>

This works in all modern browsers and doesn't require matching id and for values. It's cleaner when you have simple forms. But here's the thing -- some older assistive technologies handle implicit association inconsistently. The explicit for/id approach has universal support.

When Labels Aren't Visible

Sometimes you have a search input with a visible magnifying glass icon but no visible text label. You still need a programmatic label. Use aria-label:

<input
  type="search"
  aria-label="Search courses"
  placeholder="Search..."
/>

Don't rely on placeholder as a label replacement. Placeholders disappear when the user starts typing, which creates a memory burden ("wait, what was this field for again?"). They also typically fail color contrast requirements.

Quiz
A form input has a visible label element positioned next to it, but no for attribute and no wrapping. What does a screen reader announce when focusing the input?

Required Fields: Tell Everyone, Not Just Sighted Users

The classic pattern is a red asterisk next to the label. That works visually -- but a screen reader user has no idea that asterisk means "required" unless you also communicate it programmatically.

The Complete Pattern

<label for="full-name">
  Full name
  <span aria-hidden="true">*</span>
</label>
<input
  type="text"
  id="full-name"
  name="fullName"
  required
  aria-required="true"
/>

Here's what's happening:

  • The HTML required attribute enables native browser validation (the browser will block submission if this field is empty)
  • aria-required="true" tells screen readers this field is required -- they'll announce "Full name, required, edit text"
  • The asterisk is marked aria-hidden="true" so screen readers don't announce "Full name star" (meaningless noise)

Should You Use required or aria-required or Both?

Use both. The required attribute gives you native validation behavior. aria-required gives you the screen reader announcement. If you're doing custom validation and want to suppress native browser validation, you might use only aria-required:

<form novalidate>
  <label for="phone">Phone number</label>
  <input
    type="tel"
    id="phone"
    name="phone"
    aria-required="true"
  />
</form>
Common Trap

If your form has many required fields and only a few optional ones, flip the pattern. Instead of marking every required field, mark the optional ones with "(optional)" in the label text. Less noise, same clarity. Always include a note at the top of the form explaining the convention: "All fields are required unless marked optional."

Input Types: Free Accessibility and UX

Choosing the right type attribute is one of the highest-leverage accessibility wins. It does three things simultaneously:

  1. Mobile keyboards -- type="email" shows the @ key, type="tel" shows the number pad, type="url" shows the .com key
  2. Validation -- browsers validate format automatically for email, url, number
  3. Assistive technology -- screen readers announce the input type, giving users context
Input TypeMobile KeyboardBuilt-in ValidationScreen Reader Announcement
textStandardNone"edit text"
email@ and .com keysEmail format"edit text, email"
telNumber padNone (format varies)"edit text, telephone"
url.com and / keysURL format"edit text, URL"
numberNumeric keyboardMin/max/step"spin button"
passwordStandard (masked)None"secure edit text"
searchStandard + clearNone"search text"
dateDate pickerDate format"date edit"
<label for="user-email">Email</label>
<input type="email" id="user-email" name="email" />

<label for="phone-number">Phone</label>
<input type="tel" id="phone-number" name="phone" />

<label for="website">Website</label>
<input type="url" id="website" name="website" />
Quiz
Why should you use type=tel for phone number inputs instead of type=number?

When you have a group of related inputs -- like a set of radio buttons, a shipping address, or payment details -- wrap them in a fieldset with a legend. This gives screen readers crucial context.

Without grouping:

<!-- BAD: Screen reader says "Small, radio button" -->
<!-- User thinks: small... what? -->
<label><input type="radio" name="size" value="s" /> Small</label>
<label><input type="radio" name="size" value="m" /> Medium</label>
<label><input type="radio" name="size" value="l" /> Large</label>

With proper grouping:

<!-- GOOD: Screen reader says "T-shirt size group, Small, radio button" -->
<fieldset>
  <legend>T-shirt size</legend>
  <label><input type="radio" name="size" value="s" /> Small</label>
  <label><input type="radio" name="size" value="m" /> Medium</label>
  <label><input type="radio" name="size" value="l" /> Large</label>
</fieldset>

The legend acts as a group label. When a screen reader user tabs into the first radio button, they hear the legend text first, giving them context before they hear the individual option. Without it, "Small" is meaningless -- small what?

Use fieldset/legend for:

  • Radio button groups (always)
  • Checkbox groups (always)
  • Address sections (street, city, state, zip)
  • Date components (month, day, year as separate inputs)
  • Any logically related set of fields

Error Message Association: The aria-describedby Pattern

This is where most forms break down for screen reader users. An error message appears visually below a field, styled in red -- but the screen reader has no idea it exists because it's not programmatically linked to the input.

Linking Errors to Inputs

<label for="username">Username</label>
<input
  type="text"
  id="username"
  name="username"
  aria-describedby="username-error"
  aria-invalid="true"
/>
<span id="username-error" role="alert">
  Username must be at least 3 characters
</span>

Here's the chain:

  1. aria-describedby="username-error" tells the screen reader "there's additional descriptive text for this input -- go read the element with that id"
  2. aria-invalid="true" tells the screen reader this field has an error -- it'll announce "invalid entry" or similar
  3. role="alert" makes the error message a live region, so when it appears dynamically, screen readers announce it immediately without the user needing to navigate to it

When the screen reader focuses this input, it announces: "Username, invalid entry, edit text. Username must be at least 3 characters."

Multiple Descriptions

You can link multiple descriptions. Maybe you have a hint AND an error:

<label for="new-password">Password</label>
<input
  type="password"
  id="new-password"
  name="password"
  aria-describedby="password-hint password-error"
  aria-invalid="true"
/>
<span id="password-hint">
  Must be at least 8 characters with one number
</span>
<span id="password-error" role="alert">
  Password is too short
</span>

The screen reader reads both descriptions in order, separated by a pause. The aria-describedby value is a space-separated list of ids.

Toggling Error State

When a field is valid, remove the error indicators:

<!-- Valid state: no aria-invalid, no error message -->
<input
  type="text"
  id="username"
  name="username"
  aria-describedby="username-hint"
/>
<span id="username-hint">Choose a unique username</span>
<!-- Invalid state: aria-invalid added, error message linked -->
<input
  type="text"
  id="username"
  name="username"
  aria-describedby="username-hint username-error"
  aria-invalid="true"
/>
<span id="username-hint">Choose a unique username</span>
<span id="username-error" role="alert">
  Username is already taken
</span>
Quiz
You add an error message div below an invalid input. The error is styled in red and says 'This field is required.' A screen reader user tabs to the input. What do they hear?

Inline Validation Timing: On Blur, Not on Change

When should you validate? This sounds like a UX question, but it has deep accessibility implications.

The Problem with Validating on Every Keystroke

// BAD: Validates on every character typed
input.addEventListener('input', (e) => {
  if (e.target.value.length < 3) {
    showError('Must be at least 3 characters');
  } else {
    clearError();
  }
});

This creates a nightmare for screen reader users. Every single keystroke triggers an error announcement. Imagine typing "Jo" into a name field and your screen reader screaming "Must be at least 3 characters" after every letter. Then you type the third character and it says "Valid." Then you backspace to fix a typo and it screams again.

For users with motor disabilities using switch access or voice control, this is equally hostile -- each input event fires validation that may interfere with their input method.

The Better Pattern: Validate on Blur

// GOOD: Validates when user leaves the field
input.addEventListener('blur', (e) => {
  validateField(e.target);
});

Validate when the user finishes with a field (blur event). This respects natural input flow:

  1. User focuses the field
  2. User types their value
  3. User tabs away (blur fires)
  4. Validation runs once
  5. If invalid, the error appears and is announced

For screen reader users, this means the error is announced after they move focus -- which they can then return to fix.

Exception: Validate on Input After First Blur Error

Once a field has been shown as invalid, switch to validating on input so the user gets immediate feedback as they fix the error:

let hasBlurred = false;

input.addEventListener('blur', () => {
  hasBlurred = true;
  validateField(input);
});

input.addEventListener('input', () => {
  if (hasBlurred) {
    validateField(input);
  }
});

This is the sweet spot: no premature errors during initial entry, but responsive feedback during correction.

Why Not Debounce Instead?

You might think "just debounce the input validation by 500ms." That's better than raw keystroke validation, but still worse than blur for accessibility. Debounced validation fires at unpredictable times during input, which is disorienting for screen reader users -- they hear error announcements seemingly at random while they're still typing. The blur-then-input pattern gives deterministic, predictable timing that matches the user's mental model of "I'm done with this field."

The Error Summary Pattern

When a form submission fails, individual inline errors are not enough. You also need an error summary at the top of the form. Here's why:

  1. Screen reader users may not know how many errors exist or which fields have problems. Without a summary, they'd have to tab through every single field to discover errors
  2. Cognitive accessibility -- users with attention or memory difficulties benefit from seeing all errors in one place
  3. Keyboard users need a clear starting point to work through errors sequentially

Building an Accessible Error Summary

<div role="alert" tabindex="-1" id="error-summary">
  <h2>There are 3 errors in this form</h2>
  <ul>
    <li><a href="#email">Email address is required</a></li>
    <li><a href="#password">Password must be at least 8 characters</a></li>
    <li><a href="#terms">You must accept the terms</a></li>
  </ul>
</div>

Key details:

  • role="alert" makes it a live region -- screen readers announce it when it appears
  • tabindex="-1" makes it programmatically focusable (you'll move focus here on submit)
  • Each error links to the corresponding field using its id, so users can jump directly to the problem
  • The heading gives the count -- "3 errors" is more useful than "there are errors"

Focus Management on Submit Error

This is the critical piece most developers miss. When the form submission fails and you show the error summary, you must move focus to it:

function handleSubmit(event) {
  event.preventDefault();

  const errors = validateForm();

  if (errors.length > 0) {
    renderErrorSummary(errors);
    const summary = document.getElementById('error-summary');
    summary.focus();
    return;
  }

  submitForm();
}

Without this focus move, the screen reader user has no indication that anything happened. They pressed "Submit," and... silence. They don't know errors appeared at the top of the form. Moving focus to the error summary ensures they hear the announcement immediately.

After the user reads the summary and clicks a link to a specific field, focus moves to that field. After fixing it and tabbing forward, they encounter the next field naturally. The error summary acts as a table of contents for what needs fixing.

Common Trap

Don't move focus to the first invalid field directly -- move it to the error summary. If you jump to the first field, the user only knows about that one error. They fix it, submit again, and discover a second error. Then a third. This "whack-a-mole" pattern is frustrating for everyone and especially painful for screen reader users who can't scan the page visually to see all the red borders at once.

Quiz
After form submission fails, you render an error summary at the top of the form but don't move focus to it. What happens for a screen reader user?

The autocomplete Attribute

The autocomplete attribute tells the browser what kind of data a field expects, enabling autofill. This is an accessibility powerhouse that most developers underuse.

Why It Matters for Accessibility

  1. Motor disabilities -- autofill eliminates typing for users who find keyboard input physically difficult
  2. Cognitive disabilities -- users don't need to remember or look up information like their postal code
  3. Screen reader users -- less manual input means less opportunity for errors
  4. Everyone -- faster form completion reduces abandonment

Common Values

<input type="text" autocomplete="name" />
<input type="email" autocomplete="email" />
<input type="tel" autocomplete="tel" />
<input type="text" autocomplete="street-address" />
<input type="text" autocomplete="postal-code" />
<input type="text" autocomplete="country-name" />
<input type="text" autocomplete="cc-number" />
<input type="text" autocomplete="cc-exp" />
<input type="password" autocomplete="new-password" />
<input type="password" autocomplete="current-password" />
<input type="text" autocomplete="one-time-code" />

new-password vs current-password

This distinction matters for password managers:

<!-- Login form -->
<input type="password" autocomplete="current-password" />

<!-- Registration or change-password form -->
<input type="password" autocomplete="new-password" />

new-password tells the password manager to suggest a strong generated password. current-password tells it to offer saved credentials. Getting this wrong means your sign-up form tries to autofill an existing password instead of generating a new one.

WCAG Requirement

WCAG 2.1 Success Criterion 1.3.5 (Level AA) requires autocomplete on inputs that collect user information. This isn't a nice-to-have -- it's a compliance requirement.

Putting It All Together

Here's a complete accessible form pattern combining everything we've covered:

<form novalidate>
  <div role="alert" tabindex="-1" id="error-summary" hidden>
    <!-- Populated dynamically on submit error -->
  </div>

  <p>All fields are required unless marked optional.</p>

  <fieldset>
    <legend>Personal information</legend>

    <div>
      <label for="full-name">Full name</label>
      <input
        type="text"
        id="full-name"
        name="fullName"
        autocomplete="name"
        aria-required="true"
        required
      />
      <span id="full-name-error" hidden></span>
    </div>

    <div>
      <label for="user-email">Email</label>
      <input
        type="email"
        id="user-email"
        name="email"
        autocomplete="email"
        aria-required="true"
        required
        aria-describedby="email-hint"
      />
      <span id="email-hint">
        We will send a confirmation to this address
      </span>
      <span id="user-email-error" hidden></span>
    </div>

    <div>
      <label for="user-phone">
        Phone
        <span>(optional)</span>
      </label>
      <input
        type="tel"
        id="user-phone"
        name="phone"
        autocomplete="tel"
      />
    </div>
  </fieldset>

  <fieldset>
    <legend>Notification preferences</legend>
    <label>
      <input type="checkbox" name="notify" value="email" />
      Email notifications
    </label>
    <label>
      <input type="checkbox" name="notify" value="sms" />
      SMS notifications
    </label>
  </fieldset>

  <button type="submit">Create account</button>
</form>

Notice the patterns working together:

  • Error summary at the top with role="alert" and tabindex="-1", hidden until needed
  • "All fields required unless marked optional" convention
  • fieldset/legend grouping related fields
  • Explicit label association with for/id
  • Correct type and autocomplete attributes
  • Hint text linked via aria-describedby
  • Error spans ready to be shown and linked via aria-describedby
Key Rules
  1. 1Every input needs a programmatic label -- for/id, wrapping, or aria-label. Visual proximity is not enough.
  2. 2Use both required and aria-required='true' for required fields. Mark optional fields instead if most fields are required.
  3. 3Link error messages to inputs with aria-describedby and mark invalid fields with aria-invalid='true'.
  4. 4Validate on blur, not on every keystroke. Switch to on-input validation only after the first blur-triggered error.
  5. 5On submit failure, show an error summary at the top of the form and move focus to it with .focus().
  6. 6Always use the correct input type (email, tel, url) and autocomplete attribute for user data fields.
What developers doWhat they should do
Using placeholder text as the only label for an input
Placeholders are not reliably exposed as accessible names. Once the user types, the hint vanishes, forcing them to clear the field to remember what it was for. WCAG 1.3.1 requires programmatic labels.
Use a visible label element with for/id association. Placeholders disappear on input and usually fail contrast requirements
Showing error messages visually without aria-describedby linkage
Screen readers cannot infer visual proximity. Without aria-describedby, the error text exists in the DOM but is completely disconnected from the input it describes.
Link every error message to its input using aria-describedby and add aria-invalid='true' to the input
Validating on every keystroke with live error announcements
Keystroke validation bombards screen reader users with rapid-fire error announcements while they are still typing. It also interrupts switch access and voice control users mid-input.
Validate on blur for first validation, then on input after the first error is shown
Moving focus to the first invalid field on submit error
Jumping to the first field hides the total error count. Users fix one error, resubmit, discover another -- a frustrating loop. The error summary gives a complete picture upfront.
Move focus to an error summary at the top of the form that links to each invalid field
Using type=number for phone numbers, credit cards, or zip codes
type=number adds spinner arrows, enforces mathematical number validation, and strips leading zeros. Phone numbers, card numbers, and zip codes are numeric strings, not mathematical numbers.
Use type=tel for phones, type=text with inputmode=numeric for cards and zips
Quiz
Which autocomplete value should you use on a password field in a registration form?