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.

SC 3.3.1 identification wiring An invalid input sets aria-invalid true and aria-describedby to a message element with role alert, which is announced to assistive technology. input invalid aria-invalid="true" describedby #field-error role="alert" announced
SC 3.3.1 is satisfied when the invalid field is flagged and its text description is reachable and announced.

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

  1. With NVDA or VoiceOver, submit an invalid form and confirm each error is announced and names the field.
  2. Inspect the input: aria-invalid="true" and aria-describedby includes the error id while invalid.
  3. Correct each field and confirm aria-invalid flips to "false" and the message is removed.
  4. Assert it in Playwright: await expect(field).toHaveAttribute('aria-invalid', 'true') and await 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.

Identifying Errors on Grouped Controls

A single text input owns exactly one aria-invalid, but a set of radios or checkboxes that must be answered as a unit does not. Putting aria-invalid="true" on each of five radio buttons flags five errors for one logical question, and the description gets read on every arrow-key move through the group. The identification target for a group is the wrapping element that carries the accessible name — the <fieldset> exposed as a radiogroup — not the individual controls.

The mechanism differs from a text field in two ways. First, a <fieldset> cannot itself be described by aria-describedby in every screen reader, so the reliable pattern is to move the group into an ARIA radiogroup container and associate the message there. Second, role="alert" still lives on the message node, but the invalid flag belongs on the container, because that is the element the user perceives as “the question that was wrong”.

// Flag a required radio/checkbox group as a single unit. The container
// carries the accessible name (from its labelling element) and the
// aria-invalid state; each control stays clean so arrow-key navigation
// does not re-announce the error on every option.
export function identifyGroupError(
  group: HTMLElement,          // element with role="radiogroup" / "group"
  message: string,
): void {
  const errorId = `${group.id}-error`;
  let error = document.getElementById(errorId);
  if (!error) {
    error = document.createElement('p');
    error.id = errorId;
    error.className = 'field-error';
    group.append(error);
  }

  error.textContent = message;
  error.setAttribute('role', 'alert');

  group.setAttribute('aria-invalid', 'true');
  // aria-describedby on the container names the question's failure.
  const labelId = group.getAttribute('aria-labelledby');
  group.setAttribute(
    'aria-describedby',
    labelId ? `${labelId} ${errorId}` : errorId,
  );
}

Note that :invalid in CSS still matches each required radio, so a submit-time query over form.querySelectorAll(':invalid') will return every unselected option in the group. De-duplicate to the containing group before you identify, or you will announce the same failure once per control.

Re-Announcing an Identical Error After a Second Submit

The last checklist item — repeated identical errors still re-announce — trips more implementations than any other, and the cause is a subtle live-region rule. Assistive technology announces a live region only when its text content changes. If a user submits, reads “Email is required.”, types nothing, and submits again, textContent is written with the identical string, the DOM diff is empty, and nothing is spoken. The user gets no feedback that their second attempt also failed.

role="alert" does not exempt you from this: it controls politeness and implicit aria-live, not change detection. The fix is to force a real mutation — clear the region, yield a frame so the empty state is observed, then write the message back.

// Guarantee an announcement even when the error text is unchanged.
export function reannounce(error: HTMLElement, message: string): void {
  error.textContent = '';
  // Two rAFs: the first lets the empty state paint, the second
  // writes the message as a genuine change the live region reports.
  requestAnimationFrame(() => {
    requestAnimationFrame(() => {
      error.textContent = message;
    });
  });
}

Reach for this only on the submit path where re-announcement matters; calling it on every keystroke would produce the flood that a polite region is meant to avoid.

Why aria-invalid Timing Is a Trade-off

Flagging a field the instant its ValidityState turns invalid — on input — is technically compliant but hostile: the user is told “Email is not in a valid format” while they are still on the first character. 3.3.1 requires identification when an error is detected, and the accessible reading is that detection happens when the user signals they are done with the field, not mid-entry. The house convention is to set aria-invalid on blur for a field the user has already touched, and eagerly on submit for the whole form. Recovery is the mirror: once a field is flagged, switch it to validating on input so aria-invalid="false" and the removed message follow the very keystroke that fixes it, closing the loop the moment the constraint is satisfied.

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.

Where does aria-invalid go on a required radio group?

On the container that carries the group's accessible name — the element with role="radiogroup" or the exposed <fieldset> — not on each radio. Flagging every control repeats one logical error many times and re-announces it on every arrow-key move. De-duplicate the :invalid matches to the group before identifying, since CSS still matches each unselected option.

Why isn't my second identical error announced?

Live regions announce only when their text changes. Rewriting the same string on a second failed submit produces no DOM change, so nothing is spoken. Clear the region, wait a frame, then write the message back to force a real mutation the region reports — role="alert" governs politeness, not change detection.

← Back to WCAG 2.2 Form Compliance Checklists