- by Wouter Nordsiek
- 0 Comments
- Guide
- 2 June 2026
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.
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.
<!-- 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;">
<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.
<button type="submit">
<svg><!-- arrow icon --></svg>
</button>
<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.
<button onclick="togglePassword(event)">Show password</button>
<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.
<span class="error"></span>
<script>
// Error text is injected, but screen reader doesn't know
document.querySelector('.error').textContent = 'Invalid email';
</script>
<span class="error" role="alert"></span>
<script>
// role="alert" triggers immediate announcement
document.querySelector('.error').textContent = 'Invalid email';
</script>
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.
<label for="name">Name *</label>
<input type="text" id="name">
<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>
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. |
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.
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-invalidis 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-describedbyvalue points to an existing element ID - Verify no duplicate IDs exist across error message containers
- Confirm
aria-invalidis 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 |
