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.

SC 3.3.3 suggestion patterns by failure type Four failure types — missing, format mismatch, out of range, and likely typo — each map to a specific corrective suggestion. valueMissing typeMismatch range over/under likely typo "Enter your email address." "Use the format name@example.com." "Enter a value between 1 and 99." "Did you mean name@gmail.com?"
Each detectable failure type maps to a concrete corrective suggestion rather than a generic "invalid input".

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

  1. Trigger each constraint and confirm the message names the correction, not just “invalid”.
  2. Type name@gmial.com and confirm a “Did you mean name@gmail.com?” button appears and, when activated, fixes the value and clears the error.
  3. With a screen reader, confirm the suggestion is announced and the accept button is reachable and labelled.
  4. 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.

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.

← Back to WCAG 2.2 Form Compliance Checklists