WCAG 3.3.3 Error Suggestion Patterns
WCAG 2.2 Success Criterion 3.3.3 (Level AA) requires that when an input error is detected and a correction is known, the suggestion is provided to the user — and this recipe is a catalogue of patterns plus implementation that turns vague rejections into actionable, accessible guidance.
When to Use This Recipe
Use this once a form already identifies its errors (the Level A baseline in WCAG 3.3.1 error identification checklist) and you need to reach Level AA by telling the user how to fix each one. It applies to any field where the system can infer a correction: a missing value, a malformed email, an out-of-range number, a format mismatch, or a likely typo. It builds on the house pattern — <form novalidate> with custom rendered messages — and is the practical implementation of the suggestion half of the WCAG 2.2 form compliance checklists.
The 3.3.3 Checklist
Minimal Working Implementation
A single function maps each ValidityState failure to a corrective suggestion, reading the field’s own constraints so the message stays accurate as attributes change.
// Produce a corrective SUGGESTION (3.3.3), reading the field's constraints
// so the message reflects the actual min/max/pattern in the markup.
export function suggestCorrection(input: HTMLInputElement): string {
const v = input.validity;
const name = fieldName(input);
if (v.valueMissing) {
return `Enter your ${name}.`;
}
if (v.typeMismatch && input.type === 'email') {
return `Use the format name@example.com for your ${name}.`;
}
if (v.typeMismatch && input.type === 'url') {
return `Include https:// at the start of the ${name}.`;
}
if (v.rangeUnderflow || v.rangeOverflow) {
return `Enter a value between ${input.min || '0'} and ${input.max} for ${name}.`;
}
if (v.tooShort) {
return `Use at least ${input.minLength} characters for your ${name}.`;
}
if (v.patternMismatch) {
// The title attribute is the author's human-readable format hint.
return input.title || `Match the required format for your ${name}.`;
}
return input.validationMessage;
}
function fieldName(input: HTMLInputElement): string {
return input.labels?.[0]?.textContent?.trim().toLowerCase() ?? input.name;
}
Reading input.min, input.max, input.minLength, and input.title means the suggestion always matches the constraints declared in the markup — when an author changes max="99" to max="50", the message updates with no code edit. This derives directly from the Constraint Validation API Deep Dive flags.
The “Did You Mean…?” Typo Suggestion
For email domains, a small lookup against common misspellings turns a rejection into a one-tap fix — the most user-friendly form of 3.3.3.
const COMMON_DOMAINS = ['gmail.com', 'yahoo.com', 'outlook.com', 'hotmail.com'];
// Suggest the nearest common domain when the typed one is a likely typo.
export function suggestEmailDomain(email: string): string | null {
const at = email.lastIndexOf('@');
if (at === -1) return null;
const [local, domain] = [email.slice(0, at), email.slice(at + 1)];
for (const candidate of COMMON_DOMAINS) {
if (domain !== candidate && levenshtein(domain, candidate) <= 2) {
return `${local}@${candidate}`;
}
}
return null;
}
function levenshtein(a: string, b: string): number {
const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
for (let j = 0; j <= b.length; j++) d[0][j] = j;
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
}
}
return d[a.length][b.length];
}
Render the suggestion as an actionable button inside the associated message so keyboard and screen-reader users can accept it:
function renderSuggestion(input: HTMLInputElement, correction: string): void {
const error = document.getElementById(`${input.id}-error`)!;
error.textContent = '';
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = `Did you mean ${correction}?`;
btn.addEventListener('click', () => {
input.value = correction;
input.setCustomValidity(''); // clear so the field re-validates clean
error.textContent = '';
input.setAttribute('aria-invalid', 'false');
input.focus();
});
error.append(btn);
}
The setCustomValidity('') reset after accepting the suggestion is the lifecycle step from how to use setCustomValidity correctly — skipping it leaves the field permanently invalid.
Option Reference
| Failure | Suggestion pattern | Source of the detail |
|---|---|---|
valueMissing |
“Enter your <name>.” |
Field label |
typeMismatch (email/url) |
Name the format | Input type |
rangeUnderflow / rangeOverflow |
State allowed range | input.min / input.max |
tooShort |
State minimum length | input.minLength |
patternMismatch |
Echo the title hint |
input.title |
| Likely domain typo | “Did you mean …?” button | Levenshtein lookup |
Verification Steps
- Trigger each constraint and confirm the message names the correction, not just “invalid”.
- Type
name@gmial.comand confirm a “Did you mean name@gmail.com?” button appears and, when activated, fixes the value and clears the error. - With a screen reader, confirm the suggestion is announced and the accept button is reachable and labelled.
- Assert it in Playwright:
await expect(page.getByRole('alert')).toHaveText(/use the format name@example.com/i), as in testing form error messages with Playwright.
Edge Cases & Failure Modes
Suggestion leaks sensitive information
“That email is already registered” can reveal account existence where policy forbids it. Where required, suggest a generic next step (“Try signing in instead”) rather than confirming the value — coordinate this with your asynchronous server checks.
Range suggestion shows a blank bound
If a field has max but no min, input.min is '' and the message reads “between and 99”. Default the missing bound, as the implementation does with input.min || '0', or branch on which bound is present.
Accepting a suggestion leaves the field invalid
Setting input.value without clearing a prior setCustomValidity() string keeps customError true. Always reset with setCustomValidity('') after applying the correction, as renderSuggestion does.
Announcing the Accepted Correction Without Stealing Focus
The button in renderSuggestion clears the error node and calls input.focus(), but there is a subtle assistive-technology gap: a screen-reader user who activates the button hears nothing confirming the field is now valid. Emptying an role="alert" node does not announce anything, and the silent focus move can read as if the correction never applied. The fix is to keep the accept button and the confirmation in separate live regions so removing one never suppresses the other.
The reason is how the accessibility tree batches mutations. A single aria-live container that both loses its button and gains a confirmation string in the same task frame collapses those two changes into one diff, and most engines announce only the net text. Splitting the roles gives each change its own region and its own announcement.
// Two regions: the error/suggestion (assertive) and a calm confirmation (polite).
function acceptSuggestion(input: HTMLInputElement, correction: string): void {
const errorNode = document.getElementById(`${input.id}-error`)!;
const statusNode = document.getElementById(`${input.id}-status`)!; // aria-live="polite"
input.value = correction;
input.setCustomValidity(''); // drop any customError flag
input.setAttribute('aria-invalid', 'false');
errorNode.textContent = ''; // remove the assertive suggestion
// Defer the confirmation one frame so the alert region's removal and the
// status region's insertion land in different accessibility-tree diffs.
requestAnimationFrame(() => {
statusNode.textContent = `Applied. ${input.labels?.[0]?.textContent?.trim() ?? 'Field'} now reads ${correction}.`;
});
input.focus(); // focus returns to the field, cursor after the corrected value
}
The requestAnimationFrame boundary is the load-bearing detail: without it, VoiceOver and NVDA frequently drop the confirmation because the button removal and the status insertion race inside one microtask. Deferring by one frame is imperceptible visually but guarantees two distinct announcements. Keep the confirmation polite, not assertive — the user asked for this change, so it should not interrupt.
Deriving Suggestions from a Schema Instead of ValidityState
suggestCorrection reads native constraints, which works when validation lives in HTML attributes. Teams that validate with a schema library (Zod, Valibot) hold the same facts — a minimum, a maximum, a required flag — in the schema instead, so the suggestion should be derived from the parse issue rather than from input.validity. The mapping is mechanical, and centralising it keeps every message corrective by construction.
import { z } from 'zod';
// Map a Zod issue to a 3.3.3-compliant suggestion. Each branch names the
// correction the same way the native path does, so both validation
// strategies produce identical user-facing wording.
export function suggestFromZodIssue(issue: z.ZodIssue, fieldLabel: string): string {
switch (issue.code) {
case z.ZodIssueCode.too_small:
return issue.type === 'string'
? `Use at least ${issue.minimum} characters for your ${fieldLabel}.`
: `Enter a value of at least ${issue.minimum} for ${fieldLabel}.`;
case z.ZodIssueCode.too_big:
return `Enter a value of at most ${issue.maximum} for ${fieldLabel}.`;
case z.ZodIssueCode.invalid_string:
if (issue.validation === 'email') {
return `Use the format name@example.com for your ${fieldLabel}.`;
}
if (issue.validation === 'url') {
return `Include https:// at the start of the ${fieldLabel}.`;
}
return `Match the required format for your ${fieldLabel}.`;
case z.ZodIssueCode.invalid_type:
// A missing field parses as invalid_type (undefined received).
return issue.received === 'undefined'
? `Enter your ${fieldLabel}.`
: `Check the value entered for ${fieldLabel}.`;
default:
return issue.message; // fall back to the schema's own message
}
}
Because the schema is the single source of truth, tightening z.string().max(50) down from max(99) updates the suggestion automatically — the same guarantee the native path gets from reading input.max. Keep the default branch pointed at issue.message so a custom .refine() with its own corrective wording flows straight through untouched. Feed the returned string into the very same message node and live-region wiring described above, so the accessibility behaviour is identical no matter which engine produced the failure.
Frequently Asked Questions
What is the difference between SC 3.3.1 and 3.3.3?
3.3.1 (Level A) requires identifying and describing the error. 3.3.3 (Level AA) goes further: when a correction is known, it must be suggested. A message like "Email is invalid" satisfies 3.3.1; "Use the format name@example.com" satisfies 3.3.3.
Do I have to offer a one-tap "Did you mean…?" fix?
No. 3.3.3 requires that you suggest the correction in text; an actionable button is an enhancement, not a requirement. A plain message naming the expected format conforms. The button simply lowers the effort to apply the suggestion.
When should a suggestion be withheld?
When the suggestion would expose security-sensitive information, such as confirming whether an account or email exists. WCAG explicitly exempts cases where revealing the correction would jeopardize security. Offer a generic, non-revealing next step instead.
Should suggestions be re-computed on every keystroke?
No. Recomputing a "Did you mean…?" suggestion on every input event fires the Levenshtein
lookup mid-word and floods an assertive live region with churning text, which screen readers read aloud
continuously. Compute the suggestion on blur or on submit, once the value has settled, and let
inline validity feedback handle the moment-to-moment state. The suggestion is a correction for a completed
attempt, not a running commentary.
How do I keep suggestion wording consistent across locales?
Externalise every suggestion into your message catalogue keyed by failure type, and interpolate the
constraint values (min, max, expected format) at render time rather than
concatenating them into English fragments. A key like suggest.range with placeholders lets a
translator reorder the sentence naturally — some languages place the bounds before the verb — while the
numeric detail still comes from the field or schema. Never build the sentence by string-joining a translated
prefix with a hard-coded format example.
Related Guides
- WCAG 2.2 Form Compliance Checklists — the full set of form-relevant success criteria
- WCAG 3.3.1 Error Identification Checklist — the Level A baseline this builds on
- How to Use setCustomValidity Correctly — clear the message after applying a suggestion
- Inline Error Messaging Strategies — where and how to place the suggested correction