WCAG 3.3.1 Error Identification Checklist
WCAG 2.2 Success Criterion 3.3.1 (Level A) requires that when an input error is automatically detected, the field in error is identified and the error is described to the user in text — and this recipe is the field-by-field checklist plus implementation that makes a form pass it.
When to Use This Recipe
Use this when any form rejects input and you must prove the rejection is exposed to every user, including those relying on a screen reader. It is the direct implementation of the identification half of the WCAG 2.2 form compliance checklists, and it pairs with the house pattern: <form novalidate> so the browser’s inaccessible tooltips are suppressed and you render your own text errors. If you also need to suggest a correction, that is the sibling criterion in WCAG 3.3.3 error suggestion patterns; 3.3.1 only requires that the error be identified and described.
The 3.3.1 Checklist
Minimal Working Implementation
This renderer satisfies 3.3.1 for any field, deriving the description from the field’s ValidityState so the message names the specific failure.
// Map a failed constraint to a description. Identification (3.3.1) only
// requires that the error be described; suggesting the fix is 3.3.3.
function describeError(input: HTMLInputElement): string {
const v = input.validity;
if (v.valueMissing) return `${labelText(input)} is required.`;
if (v.typeMismatch) return `${labelText(input)} is not in a valid format.`;
if (v.tooShort) return `${labelText(input)} is too short.`;
if (v.rangeOverflow) return `${labelText(input)} is too large.`;
if (v.patternMismatch) return `${labelText(input)} does not match the expected format.`;
return input.validationMessage; // fall back to the native description
}
function labelText(input: HTMLInputElement): string {
const label = input.labels?.[0];
return label?.textContent?.trim() ?? input.name;
}
export function identifyError(input: HTMLInputElement): void {
const errorId = `${input.id}-error`;
let error = document.getElementById(errorId);
if (!error) {
error = document.createElement('p');
error.id = errorId;
error.className = 'field-error';
input.insertAdjacentElement('afterend', error);
}
error.textContent = describeError(input);
error.setAttribute('role', 'alert'); // announce immediately
input.setAttribute('aria-invalid', 'true');
// Keep the pre-existing hint description alongside the error.
const hintId = `${input.id}-hint`;
const describedBy = document.getElementById(hintId)
? `${hintId} ${errorId}`
: errorId;
input.setAttribute('aria-describedby', describedBy);
}
export function clearError(input: HTMLInputElement): void {
const error = document.getElementById(`${input.id}-error`);
error?.remove();
input.setAttribute('aria-invalid', 'false');
// Restore describedby to just the hint, if present.
const hintId = `${input.id}-hint`;
if (document.getElementById(hintId)) {
input.setAttribute('aria-describedby', hintId);
} else {
input.removeAttribute('aria-describedby');
}
}
Deriving the description from the live ValidityState is what the reading ValidityState flags for granular errors recipe formalizes; it ensures the identification text reflects the actual constraint that failed rather than a generic “invalid”.
Wiring It to Submission
3.3.1 also requires the user be moved to the problem on submit. Gate the submission and route focus.
form.addEventListener('submit', (e) => {
e.preventDefault();
if (form.checkValidity()) {
// ...dispatch
return;
}
// Identify every invalid field.
const invalid = Array.from(
form.querySelectorAll<HTMLInputElement>(':invalid')
);
invalid.forEach(identifyError);
// Move focus to the first one so keyboard/screen-reader users land on it.
invalid[0]?.focus();
});
The checkValidity() gate and focus routing follow the submission lifecycle and managing focus after validation failure.
Option Reference
| Wiring element | Required value | Purpose for 3.3.1 |
|---|---|---|
aria-invalid |
"true" while invalid, "false" on recovery |
Flags the field in error |
aria-describedby |
hint id + error id (space-separated) | Associates the description |
role="alert" on message |
present while invalid | Announces the description |
| message text | describes field + problem | The “described in text” requirement |
focus() on first invalid |
called on failed submit | Identifies the field to keyboard users |
Verification Steps
- With NVDA or VoiceOver, submit an invalid form and confirm each error is announced and names the field.
- Inspect the input:
aria-invalid="true"andaria-describedbyincludes the error id while invalid. - Correct each field and confirm
aria-invalidflips to"false"and the message is removed. - Assert it in Playwright:
await expect(field).toHaveAttribute('aria-invalid', 'true')andawait expect(page.getByRole('alert')).toBeVisible(), as shown in testing form error messages with Playwright.
Edge Cases & Failure Modes
Tooltip-only error fails 3.3.1
A form without novalidate shows a native tooltip that is not in the DOM and is inconsistently announced. Add novalidate, suppress the native UI, and render the text message — without it, the error is not reliably “described in text”.
describedby clobbers the hint
Setting aria-describedby="field-error" and dropping the existing hint id breaks the description chain. Always concatenate the hint id with the error id, as the implementation does.
Message removed but aria-invalid left true
If you remove the message node on recovery but forget aria-invalid="false", the field stays flagged invalid to assistive technology. Clear both, as clearError does.
Frequently Asked Questions
Does 3.3.1 require suggesting how to fix the error?
No. 3.3.1 (Level A) only requires identifying the field and describing the error in text. Suggesting a correction is the separate Level AA criterion 3.3.3, covered in WCAG 3.3.3 error suggestion patterns.
Is aria-invalid alone enough to identify the error?
No. aria-invalid flags the field, but 3.3.1 also requires a text description. Pair the
attribute with an associated message via aria-describedby so the error is both identified
and described.
Should the message use role="alert" or a polite live region?
Use role="alert" for an error that appears in response to a user action like submit, so it
is announced immediately. Reserve a polite live region for the aggregate status count to avoid a flood of
competing announcements.
Related Guides
- WCAG 2.2 Form Compliance Checklists — the full set of form-relevant success criteria
- WCAG 3.3.3 Error Suggestion Patterns — add a suggested correction to the identified error
- Reading ValidityState Flags for Granular Errors — derive a precise description per failure
- Testing Form Error Messages with Playwright — prove the association in a real browser