Close

Form Validation Accessibility: Real-Time Feedback with ARIA

Guide #5 · Intermediate · 20 to 30 minutes

Across the 78 NGI projects we audited, 45 form validation violations surfaced in 18 projects. On top of that, a significant portion of the 487 form-related issues we logged involve validation feedback that screen reader users never hear. Color-only error states, missing aria-invalid attributes, and error messages detached from their fields were the most common patterns.

Prerequisite: This guide builds on Guide #4: Form Labels. If your fields don't have proper <label> associations yet, fix that first, because validation messages need a foundation to attach to.

45 Validation violations found
18/78 Projects affected
487 Total form issues logged

Primary WCAG Criteria

Criterion Level What it requires
3.3.1 Error Identification A Errors must be described to the user in text
3.3.3 Error Suggestion AA Provide suggestions to fix the error when possible
3.3.2 Labels or Instructions A Provide labels or instructions for user input
4.1.2 Name, Role, Value A States and properties (like invalid) must be programmatic

Error Communication Principles

Four rules govern accessible form validation. Break any one of them and you've created a barrier for someone.

1Errors must be identifiable by text, not just color

A red border tells sighted users something is wrong. It tells users with color vision deficiency nothing. Every error needs a text description that explains what went wrong and how to fix it.

2Errors must be programmatically associated with fields

An error message sitting near a field isn't enough. Screen readers need aria-describedby to connect the error text to the input it belongs to. Without that association, a user navigating by form controls will never hear the error.

3Error summaries belong at the top of the form

When a form is submitted with multiple errors, present a summary at the top with links to each problematic field. This gives keyboard users a way to jump directly to the fields that need attention instead of tabbing through the entire form.

4Inline errors belong at the field level

Each invalid field needs its own error message, displayed adjacent to the field and connected via aria-describedby. This is what screen readers announce when the user focuses the field.

Common Problems from Our Audits

These five patterns appeared repeatedly across the NGI projects. If you're working on any form, check for these first.

Problem 1: Color-only error indicators

The most common failure. The input border turns red, but there's no text explanation of what went wrong.

✗ Inaccessible: color-only error
<!-- Border turns red, but no text error. Screen reader announces nothing. -->
<label for="email">Email</label>
<input type="email" id="email" style="border-color: red;">
✓ Accessible: text error with aria-describedby
<label for="email">Email</label>
<input type="email" id="email"
       aria-invalid="true"
       aria-describedby="email-error">
<span id="email-error" class="field-error">
  Enter a valid email address, e.g. name@example.com
</span>

Problem 2: Icon-only submit or action buttons

Buttons with only an icon (a magnifying glass, a checkmark, an arrow) and no accessible name. Screen readers announce "button" with no indication of what it does.

✗ Inaccessible: icon-only button
<button type="submit">
  <svg><!-- arrow icon --></svg>
</button>
✓ Accessible: icon button with label
<button type="submit" aria-label="Submit form">
  <svg aria-hidden="true"><!-- arrow icon --></svg>
</button>

Problem 3: Toggle buttons without state

Buttons that toggle a state (show password, enable notifications) without communicating that state to assistive technology.

✗ Inaccessible: no state information
<button onclick="togglePassword(event)">Show password</button>
✓ Accessible: aria-pressed communicates state
<button onclick="togglePassword(event)"
        aria-pressed="false">
  Show password
</button>

<script>
function togglePassword(event) {
  const btn = event.currentTarget;
  const pressed = btn.getAttribute('aria-pressed') === 'true';
  btn.setAttribute('aria-pressed', !pressed);
  // Toggle input type between 'password' and 'text'
}
</script>

Problem 4: Errors not announced to screen readers

Error messages appear in the DOM but screen readers don't announce them because there's no role="alert" or aria-live region to trigger an announcement when content changes.

✗ Inaccessible: error appears silently
<span class="error"></span>

<script>
// Error text is injected, but screen reader doesn't know
document.querySelector('.error').textContent = 'Invalid email';
</script>
✓ Accessible: live region announces the error
<span class="error" role="alert"></span>

<script>
// role="alert" triggers immediate announcement
document.querySelector('.error').textContent = 'Invalid email';
</script>
Note on role="alert" vs aria-live: Use role="alert" for critical errors that need immediate announcement (it's equivalent to aria-live="assertive"). Use aria-live="polite" for non-urgent status updates like "Password strength: strong" that can wait until the user pauses.

Problem 5: Required fields marked only by asterisks

An asterisk (*) next to a label is a visual convention. Without an explanation of what the asterisk means and without the required attribute or aria-required="true", some users won't know the field is mandatory.

✗ Inaccessible: asterisk-only, no explanation
<label for="name">Name *</label>
<input type="text" id="name">
✓ Accessible: legend, required attribute, and aria
<p class="required-note">Fields marked with * are required.</p>

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

Implementation Guide

A. Setting up aria-invalid and aria-describedby

The core pattern for inline validation is straightforward: when a field fails validation, set aria-invalid="true" on the input, show the error message, and connect them with aria-describedby.

Basic inline validation pattern

<div class="field-group">
  <label for="username">
    Username <span aria-hidden="true">*</span>
  </label>
  <input type="text" id="username"
         required
         aria-required="true"
         aria-describedby="username-error">
  <span id="username-error" class="field-error" role="alert"></span>
</div>

<script>
const field = document.getElementById('username');

field.addEventListener('blur', function() {
  const errorEl = document.getElementById('username-error');

  if (!this.value.trim()) {
    this.setAttribute('aria-invalid', 'true');
    errorEl.textContent = 'Username is required.';
    errorEl.style.display = 'block';
  } else {
    this.removeAttribute('aria-invalid');
    errorEl.textContent = '';
    errorEl.style.display = 'none';
  }
});
</script>

B. Inline validation with live feedback

For fields where real-time feedback is helpful (password strength, character counts), use aria-live="polite" so updates are announced without interrupting the user.

Password strength indicator

<div class="field-group">
  <label for="new-password">Password</label>
  <input type="password" id="new-password"
         aria-describedby="password-hint password-strength">
  <span id="password-hint" class="hint">
    Minimum 8 characters, at least one number.
  </span>
  <span id="password-strength"
        aria-live="polite"
        class="strength-indicator"></span>
</div>

<script>
document.getElementById('new-password')
  .addEventListener('input', function() {
    const strength = document.getElementById('password-strength');
    const val = this.value;

    if (val.length === 0) {
      strength.textContent = '';
    } else if (val.length < 8) {
      strength.textContent = 'Password strength: too short';
    } else if (!/\d/.test(val)) {
      strength.textContent = 'Password strength: needs a number';
    } else {
      strength.textContent = 'Password strength: strong';
    }
  });
</script>
Don't over-announce. If you validate on every keystroke with role="alert", screen readers will interrupt after each character typed. Use aria-live="polite" for real-time feedback, or validate on blur instead. Reserve role="alert" for form submission errors.

C. Error summary pattern

When a form submission fails, place a summary at the top of the form listing every error. Each item should link to its corresponding field. Move focus to the summary so screen readers announce it immediately.

Error summary with linked fields

<div id="error-summary" role="alert" tabindex="-1"
     class="error-summary" style="display:none;">
  <h3>There are 2 errors in this form</h3>
  <ul>
    <li><a href="#email">Email: enter a valid address</a></li>
    <li><a href="#password">Password: minimum 8 characters</a></li>
  </ul>
</div>

<script>
function showErrorSummary(errors) {
  const summary = document.getElementById('error-summary');
  const list = summary.querySelector('ul');
  const heading = summary.querySelector('h3');

  list.innerHTML = '';
  heading.textContent = 'There ' +
    (errors.length === 1 ? 'is 1 error' : 'are ' + errors.length + ' errors') +
    ' in this form';

  errors.forEach(function(err) {
    const li = document.createElement('li');
    const link = document.createElement('a');
    link.href = '#' + err.fieldId;
    link.textContent = err.label + ': ' + err.message;
    link.addEventListener('click', function(e) {
      e.preventDefault();
      document.getElementById(err.fieldId).focus();
    });
    li.appendChild(link);
    list.appendChild(li);
  });

  summary.style.display = 'block';
  summary.focus(); // Move focus to trigger announcement
}
</script>

D. Using role="alert" and aria-live="polite"

Two mechanisms, different purposes:

Attribute Urgency Use case
role="alert" High (assertive) Form submission errors, critical validation failures
aria-live="polite" Low (polite) Password strength, character count, search result counts
aria-live="assertive" High Same as role="alert". Use one or the other, not both.
Practical tip: The live region element must exist in the DOM before you inject content into it. If you dynamically create an element with role="alert" and set its text at the same time, some screen readers won't announce it. Create the container on page load, then update its content when errors occur.

E. Required field patterns

Use both the required HTML attribute and aria-required="true" for maximum compatibility. The HTML attribute gives you free browser validation; the ARIA attribute ensures older assistive tech picks it up.

<!-- Full required field pattern -->
<p id="required-description">
  Fields marked with <span aria-hidden="true">*</span>
  <span class="sr-only">(asterisk)</span> are required.
</p>

<label for="full-name">
  Full name <span aria-hidden="true">*</span>
</label>
<input type="text" id="full-name"
       required
       aria-required="true"
       aria-describedby="fullname-error">
<span id="fullname-error" role="alert"></span>

F. Success state feedback

Validation isn't just about errors. When a field passes validation, tell the user. Remove aria-invalid, optionally announce success for important fields.

Success feedback pattern

function markFieldValid(inputId) {
  const input = document.getElementById(inputId);
  const errorEl = document.getElementById(inputId + '-error');
  const successEl = document.getElementById(inputId + '-success');

  // Clear error state
  input.removeAttribute('aria-invalid');
  input.classList.add('valid-field');
  errorEl.textContent = '';
  errorEl.style.display = 'none';

  // Show success (optional, for critical fields)
  if (successEl) {
    successEl.textContent = 'Looks good.';
    successEl.style.display = 'block';
    // Only update aria-describedby if the success element exists
    input.setAttribute('aria-describedby', inputId + '-success');
  }
}

G. Multi-step form validation

For forms split across multiple steps or tabs, validate the current step before allowing the user to proceed. Announce errors within the current step context.

Multi-step validation approach

<!-- Step indicator for screen readers -->
<div role="group" aria-label="Registration form, step 2 of 3">
  <h2>Step 2: Contact Information</h2>

  <!-- Error summary scoped to this step -->
  <div id="step2-errors" role="alert" tabindex="-1"></div>

  <!-- Fields for this step -->
  <div class="field-group">
    <label for="phone">Phone number</label>
    <input type="tel" id="phone"
           aria-describedby="phone-error">
    <span id="phone-error"></span>
  </div>

  <button type="button" onclick="validateStep(2)">
    Continue to step 3
  </button>
</div>

<script>
function validateStep(stepNumber) {
  const errors = [];
  // Validate only fields in the current step
  const step = document.querySelector('[aria-label*="step ' + stepNumber + '"]');
  const fields = step.querySelectorAll('input[required], input[aria-required]');

  fields.forEach(function(field) {
    if (!field.value.trim()) {
      errors.push({
        fieldId: field.id,
        label: field.labels[0].textContent,
        message: 'This field is required'
      });
    }
  });

  if (errors.length > 0) {
    showStepErrors(stepNumber, errors);
  } else {
    advanceToStep(stepNumber + 1);
  }
}
</script>

Interactive Demo: Accessible Registration Form

This working form demonstrates every pattern covered above. Fill it in, trigger validation errors, and watch the "Screen Reader Output" panel to see what assistive technology would announce.

Live Demo: Registration Form with Accessible Validation

    Fields marked with are required.

    Minimum 8 characters.

    Screen Reader Output (simulated announcements):

    Testing Checklist

    Manual Testing

    • Submit the form empty and verify that every required field produces a visible text error
    • Check that error messages are not conveyed by color alone (e.g., red border must be paired with text)
    • Verify each error message suggests how to fix the problem
    • Tab through the form: each invalid field should announce its error message via the screen reader
    • Confirm the error summary appears at the top of the form on submission failure
    • Verify the error summary receives focus and is announced by screen readers
    • Click each link in the error summary and confirm it moves focus to the corresponding field
    • Fix an error and verify the error message is removed and aria-invalid is cleared
    • Check that required fields are announced as "required" by screen readers
    • Verify the asterisk convention is explained in visible text before the form

    Automated Testing

    • Run axe DevTools or Lighthouse on the form in its error state
    • Check that every aria-describedby value points to an existing element ID
    • Verify no duplicate IDs exist across error message containers
    • Confirm aria-invalid is only present on fields that are actually invalid
    • Check that role="alert" containers exist in the DOM before content is injected

    Screen Reader Testing

    • VoiceOver (macOS): Navigate with Tab. Invalid fields should announce "invalid" plus the error text
    • NVDA (Windows): Verify role="alert" triggers automatic announcement on content change
    • JAWS: Confirm error summary heading is announced when focus moves to it
    • Mobile (TalkBack/VoiceOver): Touch each field and verify errors are announced

    WCAG References

    Criterion Level Documentation
    3.3.1 Error Identification A Understanding 3.3.1 · ARIA21
    3.3.2 Labels or Instructions A Understanding 3.3.2
    3.3.3 Error Suggestion AA Understanding 3.3.3 · G85
    4.1.2 Name, Role, Value A Understanding 4.1.2
    Up next: Guide #6 will cover Accessible Focus Management, dealing with the 62 focus-related violations found across NGI projects. Getting validation right means nothing if users can't navigate to the errors.

    Leave a Reply