Accessibility pattern · Forms
Accessible form errors Working pattern · WCAG 2.2
A delivery form that tells people what went wrong and how to put it right: a summary that takes focus, a message in words beside each field, and nothing they typed thrown away. Try it with a keyboard, then copy the HTML, CSS and JavaScript that run it.
- Success criteria
- 9
- Levels
- A and AA
- Libraries needed
- None
- Script size
- 8,257 B
Try it
Send the form with mistakes
Press Save delivery details with the form empty, or type an email address without an @. Then follow a link in the summary, fix that field and send the form again.
Delivery details
The live form needs JavaScript. Its code is below, and every part of it is explained on this page.
What it does
- Checks on sending, not while you type. Nothing turns red halfway through a word.
- Moves focus to a summary. It lists every problem, each one a link to its field.
- Explains each error beside its field. In words, with an icon, joined to the field so a screen reader reads it there.
- Says how to fix it. “Enter a 6-digit PIN code, like 110001”, not “Invalid input”.
- Keeps what you typed. Nothing is cleared, and nothing jumps while you fix it: messages change when you send again.
- Confirms success out loud. A status message is read out without moving focus.
Code
Copy the code that runs it
These are the files the form above runs on, exactly as the site serves them. A test fails if the code on this page and the files ever differ.
<form class="afe" id="delivery" novalidate data-afe>
<!-- The summary: filled, shown and focused when the form is sent with errors.
Use the heading level that fits your page. -->
<div class="afe-summary" id="delivery-summary" tabindex="-1" hidden>
<h4 class="afe-summary__title">There is a problem with your details</h4>
<ul class="afe-summary__list"></ul>
</div>
<div class="afe-field">
<label class="afe-label" for="delivery-name">Full name <span class="afe-req">(required)</span></label>
<p class="afe-hint" id="delivery-name-hint">As it should appear on the parcel.</p>
<p class="afe-error" id="delivery-name-error" hidden></p>
<input class="afe-input" id="delivery-name" name="name" type="text" autocomplete="name" spellcheck="false" required aria-describedby="delivery-name-hint">
</div>
<div class="afe-field">
<label class="afe-label" for="delivery-email">Email address <span class="afe-req">(required)</span></label>
<p class="afe-hint" id="delivery-email-hint">We send updates about the delivery here.</p>
<p class="afe-error" id="delivery-email-error" hidden></p>
<input class="afe-input" id="delivery-email" name="email" type="email" autocomplete="email" spellcheck="false" required aria-describedby="delivery-email-hint">
</div>
<div class="afe-field">
<label class="afe-label" for="delivery-phone">Phone number <span class="afe-req">(optional)</span></label>
<p class="afe-hint" id="delivery-phone-hint">Only for the courier, if they cannot find you.</p>
<p class="afe-error" id="delivery-phone-error" hidden></p>
<input class="afe-input afe-input--short" id="delivery-phone" name="phone" type="tel" autocomplete="tel" aria-describedby="delivery-phone-hint">
</div>
<div class="afe-field">
<label class="afe-label" for="delivery-country">Country <span class="afe-req">(required)</span></label>
<p class="afe-error" id="delivery-country-error" hidden></p>
<!-- data-native keeps this site's custom dropdown off the select. Leave it out on yours. -->
<select class="afe-input" id="delivery-country" name="country" autocomplete="country" required data-native>
<option value="">Select a country</option>
<option value="CA">Canada</option>
<option value="IN">India</option>
<option value="GB">United Kingdom</option>
<option value="US">United States</option>
</select>
</div>
<div class="afe-field">
<label class="afe-label" for="delivery-postcode">Postcode <span class="afe-req">(required)</span></label>
<p class="afe-hint" id="delivery-postcode-hint">In India, your PIN code. In the United States, your ZIP code.</p>
<p class="afe-error" id="delivery-postcode-error" hidden></p>
<input class="afe-input afe-input--short" id="delivery-postcode" name="postcode" type="text" autocomplete="postal-code" spellcheck="false" required aria-describedby="delivery-postcode-hint">
</div>
<div class="afe-field">
<p class="afe-error" id="delivery-consent-error" hidden></p>
<div class="afe-check">
<input id="delivery-consent" name="consent" type="checkbox" value="yes" required>
<label for="delivery-consent">I agree that the courier may use these details to deliver my order <span class="afe-req">(required)</span></label>
</div>
</div>
<button class="afe-submit" type="submit">Save delivery details</button>
<p class="afe-status" role="status"></p>
</form>
/* Accessible form errors: the pattern's styles.
Colours are custom properties, so a site can point them at its own tokens.
Against --afe-bg, the defaults keep text at 4.5:1 or more and field
borders at 3:1 or more, in both colour schemes. */
.afe {
--afe-ink: #1b1b18;
--afe-ink-2: #4a4a44;
--afe-bg: #ffffff;
--afe-line: #6b6b64;
--afe-error: #b3261e;
--afe-ok: #146c3c;
display: grid;
gap: 1.25rem;
max-width: 34rem;
color: var(--afe-ink);
line-height: 1.5;
}
@media (prefers-color-scheme: dark) {
.afe {
--afe-ink: #f3f3ef;
--afe-ink-2: #c4c4bd;
--afe-bg: #1c1c1a;
--afe-line: #8f8f87;
--afe-error: #ff8a80;
--afe-ok: #7bd6a0;
}
}
.afe [hidden] { display: none !important; }
/* The summary: every error, as a link to its field. */
.afe-summary {
padding: 1rem 1.25rem;
border: 3px solid var(--afe-error);
border-radius: 8px;
scroll-margin-top: 6rem;
}
.afe-summary:focus { outline: 3px solid var(--afe-ink); outline-offset: 3px; }
.afe-summary__title { margin: 0 0 .5rem; font-size: 1.2rem; line-height: 1.3; }
.afe-summary__list { margin: 0; padding-left: 1.25rem; list-style: disc; }
.afe-summary__list li + li { margin-top: .3rem; }
.afe-summary__list a {
color: var(--afe-error);
font-weight: 600;
text-decoration: underline;
text-underline-offset: .15em;
}
/* A field: label, hint and error above the control. */
.afe-field { display: grid; gap: .3rem; }
.afe-field.is-invalid { padding-left: .9rem; border-left: 4px solid var(--afe-error); }
.afe-label { font-weight: 600; }
.afe-req { font-weight: 400; color: var(--afe-ink-2); }
.afe-hint { margin: 0; color: var(--afe-ink-2); }
.afe-error {
display: flex;
align-items: flex-start;
gap: .45rem;
margin: 0;
color: var(--afe-error);
font-weight: 600;
}
/* The icon is drawn in the text colour, so it stays visible in forced colours. */
.afe-icon {
display: inline-grid;
place-items: center;
flex: none;
box-sizing: border-box;
width: 1.3em;
height: 1.3em;
margin-top: .1em;
border: 2px solid currentColor;
border-radius: 50%;
font-size: .9em;
line-height: 1;
}
.afe-icon::before { content: "!"; font-weight: 800; }
.afe-input {
box-sizing: border-box;
width: 100%;
min-height: 2.75rem;
padding: .5rem .7rem;
border: 2px solid var(--afe-line);
border-radius: 6px;
background: var(--afe-bg);
color: var(--afe-ink);
font: inherit;
}
.afe-input--short { max-width: 14rem; }
.afe-input[aria-invalid="true"] { border-color: var(--afe-error); box-shadow: inset 0 0 0 1px var(--afe-error); }
/* The checkbox: a 24px box beside its label, so both are easy to hit. */
.afe-check { display: flex; align-items: flex-start; gap: .75rem; }
.afe-check input {
flex: none;
width: 1.5rem;
height: 1.5rem;
margin: 0;
accent-color: var(--afe-ink);
}
.afe-submit {
justify-self: start;
min-height: 2.75rem;
padding: .6rem 1.25rem;
border: 2px solid var(--afe-ink);
border-radius: 6px;
background: var(--afe-ink);
color: var(--afe-bg);
font: inherit;
font-weight: 700;
cursor: pointer;
}
.afe-input:focus-visible,
.afe-check input:focus-visible,
.afe-submit:focus-visible { outline: 3px solid var(--afe-ink); outline-offset: 2px; }
/* The status line stays rendered while empty: a live region that is not in
the page when its words arrive is not read out. */
.afe-status { margin: 0; color: var(--afe-ok); font-weight: 600; }
/* Read by screen readers, not shown. */
.afe-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/**
* Accessible form errors: the behaviour.
*
* Enhances every <form data-afe>. The form is checked when it is sent, never
* while someone types. When anything is wrong:
* - each field in error gets its message in words, with an icon, next to it
* (joined to the field by aria-describedby), and is marked aria-invalid;
* - the summary at the top lists every message as a link to its field and
* takes focus, so a screen reader reads it straight away;
* - nothing that was typed is cleared.
* Messages change only when the form is sent again. Clearing one as someone
* leaves a field would move everything below it, and a click on the button
* that caused the leaving could then land somewhere else. When every field is
* valid, the status line says so (role="status": read out, focus stays put).
*/
import { FIELDS, SAVED, validate } from "./form-rules.js";
/** The form's values by field name. A box that is not ticked counts as "". */
function valuesOf(form) {
const values = {};
for (const name of FIELDS) {
const field = form.elements.namedItem(name);
if (!field) continue;
values[name] = field.type === "checkbox" ? (field.checked ? field.value : "") : field.value;
}
return values;
}
export function enhance(form) {
const summary = form.querySelector(".afe-summary");
const list = form.querySelector(".afe-summary__list");
const status = form.querySelector(".afe-status");
const fields = FIELDS.map((name) => form.elements.namedItem(name)).filter(Boolean);
// Each field's own description (its hint), so an error can be added to it and taken off again.
const hints = new Map(fields.map((field) => [field, field.getAttribute("aria-describedby") || ""]));
function showError(field, message) {
const box = document.getElementById(`${field.id}-error`);
const hint = hints.get(field);
field.closest(".afe-field")?.classList.toggle("is-invalid", Boolean(message));
if (!message) {
box.replaceChildren();
box.hidden = true;
field.removeAttribute("aria-invalid");
if (hint) field.setAttribute("aria-describedby", hint);
else field.removeAttribute("aria-describedby");
return;
}
const icon = document.createElement("span");
icon.className = "afe-icon";
icon.setAttribute("aria-hidden", "true");
const prefix = document.createElement("span");
prefix.className = "afe-sr";
prefix.textContent = "Error:";
const words = document.createElement("span");
words.textContent = message;
box.replaceChildren(icon, prefix, " ", words);
box.hidden = false;
field.setAttribute("aria-invalid", "true");
field.setAttribute("aria-describedby", `${hint} ${box.id}`.trim());
}
function summaryLink(field, message) {
const item = document.createElement("li");
const link = document.createElement("a");
link.href = `#${field.id}`;
link.textContent = message;
item.append(link);
return item;
}
// A link in the summary shows its field with the label above it, then moves focus there.
summary.addEventListener("click", (event) => {
const link = event.target.closest("a[href^='#']");
const field = link && document.getElementById(link.getAttribute("href").slice(1));
if (!field) return;
event.preventDefault();
(field.closest(".afe-field") || field).scrollIntoView({ block: "center" });
field.focus({ preventScroll: true });
});
form.addEventListener("submit", (event) => {
// This example sends nothing. A real form sends the data at the end of
// this handler (with fetch), or stops the browser only when there are
// errors and lets it submit; either way, the server checks it again.
event.preventDefault();
const errors = validate(valuesOf(form));
for (const field of fields) showError(field, errors.find((e) => e.name === field.name)?.message || "");
status.textContent = "";
if (errors.length) {
list.replaceChildren(...errors.map((e) => summaryLink(form.elements.namedItem(e.name), e.message)));
summary.hidden = false;
summary.focus();
return;
}
list.replaceChildren();
summary.hidden = true;
// Written a moment after it was emptied, so a second success is read out too.
setTimeout(() => { status.textContent = SAVED; }, 100);
});
}
if (typeof document !== "undefined") {
for (const form of document.querySelectorAll("form[data-afe]")) enhance(form);
}
/**
* Accessible form errors: the rules.
*
* Pure functions with no DOM, so they run (and are tested) anywhere. Each rule
* takes a field's value, and all the values for rules that depend on another
* field, and returns "" when the value is fine or the message to show when it
* is not. Every message says what to do, not only what is wrong (WCAG 3.3.3),
* and the same words appear in the summary and beside the field.
*/
/** The fields in the order they appear, so errors are listed in that order. */
export const FIELDS = ["name", "email", "phone", "country", "postcode", "consent"];
/** What the form says when every field is valid. */
export const SAVED = "Your delivery details are complete. This example sends nothing: they stay on this page.";
/** Postcode formats for the countries the form offers. Spaces and case are forgiven. */
export const POSTCODES = {
CA: { re: /^[A-Z][0-9][A-Z] ?[0-9][A-Z][0-9]$/, message: "Enter a Canadian postal code, like K1A 0B1" },
IN: { re: /^[1-9][0-9]{2} ?[0-9]{3}$/, message: "Enter a 6-digit PIN code, like 110001" },
GB: { re: /^[A-Z]{1,2}[0-9][A-Z0-9]? ?[0-9][A-Z]{2}$/, message: "Enter a UK postcode, like EC1A 1BB" },
US: { re: /^[0-9]{5}(-[0-9]{4})?$/, message: "Enter a 5-digit ZIP code, like 10001, or a ZIP+4 code, like 10001-1234" },
};
/** Collapse runs of spaces and trim: what people type is not what they mean to be checked. */
const tidy = (value) => String(value ?? "").replace(/\s+/g, " ").trim();
export const RULES = {
// Names have no reliable format, so the only check is that one was given.
name(value) {
return tidy(value) ? "" : "Enter your full name";
},
email(value) {
const v = tidy(value);
if (!v) return "Enter your email address";
if (!v.includes("@")) return "Enter an email address with an @, like [email protected]";
const [, domain = ""] = v.split("@");
if (!domain.includes(".")) return "Enter the part after the @, like [email protected]";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) return "Enter an email address in the right format, like [email protected]";
return "";
},
// Optional: an empty phone number is fine, a wrong one is not.
phone(value) {
const v = tidy(value);
if (!v) return "";
if (!/^\+?[0-9 ().-]+$/.test(v)) return "Use only digits, spaces, brackets and hyphens, with + only at the start";
const digits = v.replace(/[^0-9]/g, "").length;
if (digits < 7 || digits > 15) return "Enter a phone number with 7 to 15 digits, or leave it empty";
return "";
},
country(value) {
return POSTCODES[tidy(value)] ? "" : "Select the country for delivery";
},
postcode(value, values = {}) {
const v = tidy(value).toUpperCase();
if (!v) return "Enter your postcode";
const format = POSTCODES[tidy(values.country)];
// Without a country there is no format to check against; the country has its own error.
return !format || format.re.test(v) ? "" : format.message;
},
consent(value) {
return value ? "" : "Select the box to agree that the courier may use these details";
},
};
/** Every problem with a set of values, in field order: [{ name, message }]. */
export function validate(values = {}) {
const errors = [];
for (const name of FIELDS) {
const message = RULES[name](values[name], values);
if (message) errors.push({ name, message });
}
return errors;
}
- Add the markup and the styles. Keep the ids in step: each field’s hint and error use its id followed by “-hint” and “-error”.
- Load the script as a module, next to the rules. It enhances every form marked data-afe, and needs nothing else.
<script type="module" src="form-errors.js"></script> - Write your own messages in the rules. Each rule returns nothing when a value is fine, or a sentence that says what to do.
- Check again on the server. Without JavaScript the browser sends the form, so the server should send back the same summary and messages, with the values filled in.
What goes wrong
The same form, built the usual way
This version makes the mistakes found on many real forms. Open it, type into it, send it with mistakes, and compare.
It fails on purpose, so it stays closed until you open it. The same fields, the same checks: only the markup and the way errors are shown differ.
The broken version needs JavaScript to show its errors.
What is wrong with it
- Placeholders instead of labels. The name of each field disappears as soon as you type, so you cannot check what a box was for. 3.3.2
- Errors shown only in red. A border changes colour and nothing else, so people who cannot see red, and screen reader users, are not told which field is wrong. 1.4.1 3.3.1
- One vague message, not linked to anything. “There is an error in the form” does not say where or what, and it is not announced. 3.3.3
- Checked on every key press. The email box turns red at the first letter, before anyone could have finished it.
- No autocomplete tokens. The browser has to guess what each box is for before it can fill in a saved address. 1.3.5
An automated check passes it. axe-core 4.12.1 found no violations in the broken version, open and sent with mistakes: browsers use a placeholder as a field’s name, and no tool can tell that red is the only sign of an error. That is why the keyboard and screen reader checks matter.
What a screen reader is expected to say
Worked out from the markup and the ARIA and HTML specifications, not recorded with a screen reader. Exact words differ between screen readers.
| Moment | This pattern | The broken version |
|---|---|---|
| Tabbing to the first field | “Full name (required), edit, required”, then the hint “As it should appear on the parcel.” | “Full name, edit”: the placeholder stands in as the name, and is gone from the screen once you type. |
| Sending the form with mistakes | Focus moves to the summary, so its heading and the list of problems are read. | Nothing. Focus stays on the button, and the message at the top is not announced. |
| Reaching a field in error | The label, “invalid entry”, then the hint and “Error: Enter your full name”. | The placeholder name only. The red border is not announced. |
| Sending a valid form | “Your delivery details are complete…”, read out from the status message. | Nothing, unless the person goes looking. |
WCAG 2.2
What it covers
The success criteria this form meets, and how.
A form that meets them is one part of a page. The rest of the page has to meet them too, and that is still not a conformance claim on its own.
All WCAG criteria| Criterion | Level | How this form meets it |
|---|---|---|
| 1.3.1 Info and Relationships | A | Each label is tied to its field with for and id; hints and errors are tied with aria-describedby; the summary is a heading and a list. |
| 1.3.5 Identify Input Purpose | AA | The fields about the person carry autocomplete tokens: name, email, tel, country and postal-code. |
| 1.4.1 Use of Color | A | An error is shown in words, with an icon and a thicker border and bar, never by colour alone. |
| 3.2.2 On Input | A | Typing or choosing a value changes nothing else on the page; checks run when you press the button. |
| 3.3.1 Error Identification | A | Each field in error is marked aria-invalid and described in text beside it and in the summary. |
| 3.3.2 Labels or Instructions | A | Visible labels that stay on screen, required and optional said in words, and hints for the format. |
| 3.3.3 Error Suggestion | AA | Every message says how to fix the problem, with an example where a format is expected. |
| 4.1.2 Name, Role, Value | A | Native inputs, a native select and a native checkbox, each named by its label, with the required and invalid states exposed. |
| 4.1.3 Status Messages | AA | The success message is a role="status" region, read out without taking focus. |
Not covered by this form
- 3.3.4 Error Prevention (Legal, Financial, Data): a delivery form commits no one to anything. A payment or an order needs a review step, or a way to undo it.
- 3.3.7 Redundant Entry: in a longer process, fill in what the person has already given you, such as a billing address.
- 2.4.11 Focus Not Obscured (Minimum): a sticky header or a cookie banner can still hide the field a summary link moves you to; check with your own page.
- 1.4.3 Contrast (Minimum): the default colours are checked, but colours you swap in are yours to check.
Testing notes
How it was tested
What was checked, with what, and what was not.
Checked on 4 October 2026 in Chromium, with the keyboard steps below run by a script.
Keyboard, step by step
- Press Tab from the top of the form. Focus goes to Full name, Email address, Phone number, Country, Postcode, the checkbox, then Save delivery details.
- With every field empty, press Enter in any text field, or Space on the button.
- Focus moves to the summary. It lists five problems; the phone number is optional, so it is not one of them.
- Press Tab to reach the first link, Enter your full name, and press Enter. Focus moves into the Full name field, with its label in view.
- Type a name and press Enter to send the form again. Focus returns to the summary, which now lists four problems; the message beside Full name is gone.
- Fill in the rest, tick the box with Space, and send the form again. The success message appears and focus stays on the button.
- Press Shift+Tab back through the form. Everything you typed is still there.
Screen readers: what to expect
- Not yet tested with NVDA, JAWS, VoiceOver or TalkBack. The expected announcements in the table above come from the markup, not from a recording.
- Descriptions (the hint and the error) are usually read after a short pause, after the label and the state.
- The summary is announced because it takes focus. It has no role="alert", so it should not be read twice.
Checked automatically
- axe-core 4.12.1: no violations on this page, with the form empty, after a send with mistakes and after a valid send, in the light and dark themes, at 1280 and 390 pixels wide.
- Playwright runs the keyboard steps above and checks focus, aria-invalid, aria-describedby, the summary links and the status message at each one.
- Unit tests run every rule in form-rules.js against valid and invalid values, and check that the code on this page matches the files byte for byte.
Known limits
- “Required” is in each label and in the required attribute, so some screen readers say it twice. Drop one if your testing shows it gets in the way.
- Postcode formats are checked for four countries only, and names only for being there: people’s names follow no rules.
- The messages in the code are English. On this site the Hindi page translates them; on yours, translate them in form-rules.js.
- Checking in the browser is a courtesy, not a safeguard. The server must check again.
Design decisions
Why it works this way
The choices behind the pattern, for when you adapt it.
Why check when the form is sent, not as people type?
An error that appears mid-word is wrong, distracting, and may be read out over what the person is typing. Checking on sending gives one clear moment with every problem at once. Messages also stay until the next send: clearing one as you leave a field would move everything below it, and a click on the button could land in the wrong place.
Why a summary and a message beside each field?
The summary tells you how many problems there are and takes you to each one. The message beside the field is what you read while you fix it, and what a screen reader reads when you arrive. Both use the same words, so they are easy to match up.
Why move focus to the summary, not to the first field?
Landing in the first field tells you about one problem. Landing on the summary tells you about all of them, and each one is a single link away. On a short form, moving to the first field is also a reasonable choice.
Why not role="alert" on the summary?
Moving focus to the summary already makes a screen reader read it. An alert on top of that can make it read twice. Use an alert only when focus cannot move, for example when errors arrive from the server while the person is somewhere else on the page.
Why not disable the button until the form is valid?
A disabled button cannot be focused in most browsers and does not say why it is disabled. People are left guessing which field is wrong. An enabled button that answers with a list of problems tells them.
Why not use the browser’s own error bubbles?
They show one error at a time, vanish after a few seconds, cannot be styled or reworded per field, and are handled differently by each browser and screen reader. The form turns them off with novalidate but keeps required, type and autocomplete, which still tell assistive technology what each field is.
Next
Check your own forms
The checklist items this pattern answers, a tool to scan a page, and a template to write up what you find.
- ChecklistEvery control has a programmatic label
- ChecklistErrors are identified in text and tied to the field
- URL AnalyzerScan a page for missing labels, autocomplete tokens and more
- Audit templateWrite up findings with severity, owners and retests
Live demos of the criteria on their own: 3.3.1 Error Identification, 3.3.2 Labels or Instructions, 3.3.3 Error Suggestion and 1.3.5 Identify Input Purpose.
Sources
Where this comes from
Written and tested by Auric Artisan. Not yet reviewed by an independent accessibility specialist.
Report a problem with this page- Web Content Accessibility Guidelines (WCAG) 2.2w3.org
- W3C WAI Forms Tutorial: User Notificationw3.org
- Technique G139: Creating a mechanism that allows users to jump to errorsw3.org
- Technique ARIA21: Using aria-invalid to Indicate An Error Fieldw3.org
- Technique G177: Providing suggested correction textw3.org
- Technique ARIA22: Using role=status to present status messagesw3.org
- WCAG 2.2, section 7: Input Purposes for User Interface Componentsw3.org