Accessibility pattern · Forms and flows
Inline validation
A field is checked when it is left, and only if something was typed in it; while an error is being put right, it is checked again on every change, so the fix shows at once. Messages are words with an icon, joined to the field by aria-describedby, and sending the form still lists every problem in a summary that takes focus.
- WCAG criteria
- 7
- Keyboard rules
- 4
- Checked with
- axe, keyboard and the inspector
Live demo
Try it
Use it with a mouse, a keyboard or a screen reader. The inspector beside it shows what the browser tells assistive technology as you go: focus, state changes and announcements.
The demo works without JavaScript only as far as its HTML does; the inspector needs JavaScript.
Keyboard
Keys it answers to
Every action works without a pointer.
| Key | What it does |
|---|---|
| Tab | Moves to the next field. A field you typed in is checked as you leave it; one you only passed through is left alone. |
| ShiftTab | Moves to the previous field, with the same rule. |
| Enter | In a field, sends the form: every field is checked, and if any are wrong the summary lists them and takes focus. |
| Enter | On a link in the summary, moves focus to that field. |
Screen readers
What it announces
Written from the roles, names and states in the markup.
| When | Expected announcement |
|---|---|
| Focus reaches Email address | Email address, edit text, required. We send a link to confirm it. |
| Tab leaves Email address after typing asha.mail.in | Enter an email address with an @, like [email protected] |
| Focus goes back to Email address | Email address, edit text, required, invalid entry. We send a link to confirm it. Error: Enter an email address with an @, like [email protected] |
| Typing the @ puts it right | Email address looks right |
| Create account is pressed with fields still wrong | There is a problem with your details, group. Enter your full name. Enter a password. |
| Every field is right and the form is sent | Account created. This demo sends nothing. |
These are expected announcements, not recordings. Wording and order differ between screen readers and browsers.
Code
Copy the code
The exact files this demo runs on. The styles are served with a prefix that keeps this site's own styles out of the demo; what you copy is the original.
<form class="ap-inline-validation" data-ap-inline-validation novalidate aria-labelledby="iv-form-name">
<div class="ap-inline-validation__top">
<h3 class="ap-inline-validation__name" id="iv-form-name">Create your account</h3>
<p class="ap-inline-validation__sub">Every field is required.</p>
</div>
<div class="ap-inline-validation__summary" id="iv-summary" role="group" aria-labelledby="iv-summary-name" aria-describedby="iv-summary-list" tabindex="-1" hidden>
<h4 class="ap-inline-validation__summary-name" id="iv-summary-name"><svg class="ap-inline-validation__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg>There is a problem with your details</h4>
<ul class="ap-inline-validation__summary-list" id="iv-summary-list"></ul>
</div>
<div class="ap-inline-validation__body">
<div class="ap-field ap-inline-validation__field">
<label class="ap-label" for="iv-name">Full name</label>
<input class="ap-input" id="iv-name" name="name" type="text" autocomplete="name" spellcheck="false" required />
<p class="ap-inline-validation__msg" id="iv-name-msg"><svg class="ap-inline-validation__icon ap-inline-validation__icon--bad" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg><svg class="ap-inline-validation__icon ap-inline-validation__icon--ok" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="m8 12.5 2.6 2.6L16 9.6"/></svg><span class="ap-inline-validation__vh" data-ap-prefix hidden>Error:</span> <span data-ap-text></span></p>
</div>
<div class="ap-field ap-inline-validation__field">
<label class="ap-label" for="iv-email">Email address</label>
<p class="ap-hint" id="iv-email-hint">We send a link to confirm it.</p>
<input class="ap-input" id="iv-email" name="email" type="email" autocomplete="email" spellcheck="false" required aria-describedby="iv-email-hint" />
<p class="ap-inline-validation__msg" id="iv-email-msg"><svg class="ap-inline-validation__icon ap-inline-validation__icon--bad" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg><svg class="ap-inline-validation__icon ap-inline-validation__icon--ok" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="m8 12.5 2.6 2.6L16 9.6"/></svg><span class="ap-inline-validation__vh" data-ap-prefix hidden>Error:</span> <span data-ap-text></span></p>
</div>
<div class="ap-field ap-inline-validation__field">
<label class="ap-label" for="iv-username">Username</label>
<p class="ap-hint" id="iv-username-hint">3 to 20 letters, numbers, full stops or underscores.</p>
<input class="ap-input" id="iv-username" name="username" type="text" autocomplete="username" autocapitalize="none" spellcheck="false" required aria-describedby="iv-username-hint" />
<p class="ap-inline-validation__msg" id="iv-username-msg"><svg class="ap-inline-validation__icon ap-inline-validation__icon--bad" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg><svg class="ap-inline-validation__icon ap-inline-validation__icon--ok" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="m8 12.5 2.6 2.6L16 9.6"/></svg><span class="ap-inline-validation__vh" data-ap-prefix hidden>Error:</span> <span data-ap-text></span></p>
</div>
<div class="ap-field ap-inline-validation__field">
<label class="ap-label" for="iv-password">Password</label>
<p class="ap-hint" id="iv-password-hint">At least 10 characters. A few words in a row are easy to remember.</p>
<input class="ap-input" id="iv-password" name="password" type="password" autocomplete="new-password" required aria-describedby="iv-password-hint" />
<p class="ap-inline-validation__msg" id="iv-password-msg"><svg class="ap-inline-validation__icon ap-inline-validation__icon--bad" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg><svg class="ap-inline-validation__icon ap-inline-validation__icon--ok" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="m8 12.5 2.6 2.6L16 9.6"/></svg><span class="ap-inline-validation__vh" data-ap-prefix hidden>Error:</span> <span data-ap-text></span></p>
</div>
</div>
<p class="ap-inline-validation__vh" role="status" data-ap-live></p>
<div class="ap-inline-validation__foot">
<p class="ap-inline-validation__status" role="status"><svg class="ap-inline-validation__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="m8 12.5 2.6 2.6L16 9.6"/></svg><span data-ap-done></span></p>
<button type="submit" class="ap-btn ap-btn--primary">Create account</button>
</div>
</form>
/* Shared primitives: buttons and form fields used across the patterns.
Uses the --ap-* design tokens. */
.ap-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 40px;
padding: 0 16px;
border: 1px solid var(--ap-border-strong);
border-radius: var(--ap-radius-sm);
background: var(--ap-surface);
color: var(--ap-text);
font: inherit;
font-weight: 600;
line-height: 1.2;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: background-color var(--ap-duration) var(--ap-ease), border-color var(--ap-duration) var(--ap-ease), box-shadow var(--ap-duration) var(--ap-ease);
}
.ap-btn:hover {
background: var(--ap-surface-2);
}
.ap-btn:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 2px;
}
.ap-btn:disabled,
.ap-btn[aria-disabled="true"] {
opacity: .55;
cursor: not-allowed;
}
.ap-btn--primary {
border-color: var(--ap-accent);
background: var(--ap-accent);
color: var(--ap-on-accent);
box-shadow: var(--ap-shadow-sm);
}
.ap-btn--primary:hover {
border-color: var(--ap-accent-hover);
background: var(--ap-accent-hover);
}
.ap-btn--danger {
border-color: var(--ap-danger);
background: var(--ap-danger);
color: var(--ap-surface);
}
.ap-btn--danger:hover {
filter: brightness(1.08);
}
.ap-btn--ghost {
border-color: transparent;
background: transparent;
}
.ap-btn--ghost:hover {
background: var(--ap-surface-2);
}
.ap-btn__icon {
width: 18px;
height: 18px;
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.ap-field {
display: grid;
gap: 6px;
}
.ap-label {
color: var(--ap-text);
font-weight: 600;
font-size: .9375rem;
}
.ap-hint {
margin: 0;
color: var(--ap-text-3);
font-size: .875rem;
line-height: 1.45;
}
.ap-error {
display: flex;
align-items: flex-start;
gap: 6px;
margin: 0;
color: var(--ap-danger);
font-size: .875rem;
font-weight: 600;
line-height: 1.45;
}
.ap-input {
width: 100%;
min-height: 44px;
padding: 10px 12px;
border: 1px solid var(--ap-border-strong);
border-radius: var(--ap-radius-sm);
background: var(--ap-surface);
color: var(--ap-text);
font: inherit;
line-height: 1.4;
transition: border-color var(--ap-duration) var(--ap-ease), box-shadow var(--ap-duration) var(--ap-ease);
}
.ap-input::placeholder {
color: var(--ap-text-3);
}
.ap-input:hover {
border-color: var(--ap-text-2);
}
.ap-input:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 1px;
border-color: var(--ap-focus);
}
.ap-input[aria-invalid="true"] {
border-color: var(--ap-danger);
box-shadow: inset 4px 0 0 var(--ap-danger);
}
textarea.ap-input {
resize: vertical;
min-height: 88px;
}
@media (prefers-reduced-motion: reduce) {
.ap-btn,
.ap-input { transition: none; }
}
/* Inline validation. Uses the --ap-* design tokens and the shared primitives. */
.ap-inline-validation {
width: min(100%, 520px);
margin-inline: auto;
border: 1px solid var(--ap-border);
border-radius: var(--ap-radius-lg);
background: var(--ap-surface);
color: var(--ap-text);
box-shadow: var(--ap-shadow-md);
container-type: inline-size;
}
.ap-inline-validation [hidden] {
display: none;
}
.ap-inline-validation__top {
padding: 22px 24px 0;
}
.ap-inline-validation__name {
margin: 0;
font-size: 1.125rem;
font-weight: 650;
line-height: 1.3;
}
.ap-inline-validation__sub {
margin: 4px 0 0;
color: var(--ap-text-3);
font-size: .875rem;
line-height: 1.45;
}
/* The summary: every problem from the last attempt to send, as links. */
.ap-inline-validation__summary {
margin: 18px 24px 0;
padding: 14px 16px 14px 18px;
border: 1px solid var(--ap-danger);
border-left-width: 4px;
border-radius: var(--ap-radius-sm);
background: var(--ap-surface);
scroll-margin-top: 96px;
}
.ap-inline-validation__summary:focus-visible,
.ap-inline-validation__summary:focus {
outline: 2px solid var(--ap-focus);
outline-offset: 3px;
}
.ap-inline-validation__summary-name {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 0;
font-size: 1rem;
font-weight: 650;
line-height: 1.4;
}
.ap-inline-validation__summary-name .ap-inline-validation__icon {
width: 20px;
height: 20px;
margin-top: 1px;
color: var(--ap-danger);
}
.ap-inline-validation__summary-list {
display: grid;
gap: 4px;
margin: 8px 0 0;
padding-left: 48px;
}
.ap-inline-validation__summary-list a {
color: var(--ap-danger);
font-weight: 600;
line-height: 1.45;
text-decoration: underline;
text-underline-offset: .18em;
}
.ap-inline-validation__summary-list a:hover {
text-decoration-thickness: 2px;
}
.ap-inline-validation__summary-list a:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 2px;
border-radius: 2px;
}
.ap-inline-validation__body {
display: grid;
gap: 12px;
padding: 18px 24px 10px;
}
.ap-inline-validation__field {
min-width: 0;
}
.ap-inline-validation .ap-hint {
margin-top: -2px;
}
/* A field that passed: a success edge, next to the tick in words. */
.ap-inline-validation__field[data-state="ok"] .ap-input {
border-color: var(--ap-success);
}
/* The message line keeps its height while empty, so nothing below moves when words arrive. */
.ap-inline-validation__msg {
display: flex;
align-items: flex-start;
gap: 6px;
min-height: calc(.875rem * 1.45);
margin: 0;
font-size: .875rem;
font-weight: 600;
line-height: 1.45;
}
.ap-inline-validation__msg[data-tone="bad"] {
color: var(--ap-danger);
}
.ap-inline-validation__msg[data-tone="ok"] {
color: var(--ap-success);
}
.ap-inline-validation__icon {
width: 16px;
height: 16px;
flex-shrink: 0;
margin-top: 2px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.ap-inline-validation__msg .ap-inline-validation__icon {
display: none;
}
.ap-inline-validation__msg[data-tone="bad"] .ap-inline-validation__icon--bad,
.ap-inline-validation__msg[data-tone="ok"] .ap-inline-validation__icon--ok {
display: block;
animation: ap-inline-validation-pop var(--ap-duration) var(--ap-ease);
}
.ap-inline-validation__vh {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* Footer: the outcome beside the button. */
.ap-inline-validation__foot {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 12px 16px;
padding: 16px 24px;
border-top: 1px solid var(--ap-border);
border-radius: 0 0 var(--ap-radius-lg) var(--ap-radius-lg);
background: var(--ap-surface-2);
}
.ap-inline-validation__status {
display: flex;
flex: 1 1 200px;
align-items: flex-start;
gap: 8px;
margin: 0;
color: var(--ap-success);
font-size: .9375rem;
font-weight: 600;
line-height: 1.45;
}
.ap-inline-validation__status .ap-inline-validation__icon {
display: none;
width: 18px;
height: 18px;
margin-top: 1px;
}
.ap-inline-validation__status[data-tone="ok"] .ap-inline-validation__icon {
display: block;
}
.ap-inline-validation__foot .ap-btn {
min-height: var(--ap-target);
}
@keyframes ap-inline-validation-pop {
from { opacity: 0; transform: scale(.6); }
}
@container (max-width: 440px) {
.ap-inline-validation__top { padding: 18px 18px 0; }
.ap-inline-validation__summary { margin: 16px 18px 0; }
.ap-inline-validation__summary-list { padding-left: 32px; }
.ap-inline-validation__body { padding: 16px 18px 8px; }
.ap-inline-validation__foot { padding: 14px 18px; }
.ap-inline-validation__status:empty,
.ap-inline-validation__status:not([data-tone]) { flex-basis: 0; }
.ap-inline-validation__foot .ap-btn { flex: 1 1 100%; }
}
@media (prefers-reduced-motion: reduce) {
.ap-inline-validation__msg[data-tone] .ap-inline-validation__icon { animation: none; }
}
/**
* Inline validation: each field is checked when it is left, but only once
* something has been typed in it; sending the form checks everything and
* lists the problems in a summary that takes focus.
*
* Markup: form[data-ap-inline-validation][novalidate] holding inputs with a
* name in RULES, each followed by p#<input id>-msg (an icon for each tone,
* [data-ap-prefix] "Error:" for screen readers, [data-ap-text] for the
* words); a hidden summary (#iv-summary with a ul); a visually hidden
* role=status [data-ap-live] for the fields; and a role=status beside the
* submit button for the outcome.
*
* Reward early, punish late: after its first check, a field in error is
* checked on every change so the fix shows at once, but a new error waits
* until the field is left.
*/
const TAKEN = new Set(["asha", "admin", "rahul", "priya", "support"]);
const COMMON = new Set(["password12", "1234567890", "qwertyuiop", "iloveyou12", "password123"]);
// Each rule returns "" when the value is fine, or what to do about it.
const RULES = {
name: (v) => (v.trim() ? "" : "Enter your full name"),
email: (v) => {
const s = v.trim();
if (!s) return "Enter your email address";
if (!s.includes("@")) return "Enter an email address with an @, like [email protected]";
const domain = s.slice(s.indexOf("@") + 1);
if (!domain) return "Enter the part after the @, like mail.in";
return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(s) ? "" : "Enter an email address like [email protected]";
},
username: (v) => {
const s = v.trim();
if (!s) return "Enter a username";
if (!/^[a-z0-9._]+$/i.test(s)) return "Use only letters, numbers, full stops and underscores in your username";
if (s.length < 3) return "Enter a username of at least 3 characters";
if (s.length > 20) return "Enter a username of 20 characters or fewer";
return TAKEN.has(s.toLowerCase()) ? "That username is taken. Try adding a word or a number" : "";
},
password: (v) => {
if (!v) return "Enter a password";
if (v.length < 10) return "Enter a password of at least 10 characters";
return COMMON.has(v.toLowerCase()) ? "That password is too common. Try a few words in a row" : "";
},
};
// What a tick says. A name has no format to get right, so it gets none.
const TICKS = {
email: "Email address looks right",
username: "That username is free",
password: "Password is long enough",
};
const SENT = "Account created. This demo sends nothing.";
const ANNOUNCE_DELAY = 100; // empty the region first, then write: the change is what gets read
export function init(root) {
const fields = [...root.querySelectorAll("input[name]")].filter((input) => RULES[input.name]);
const submit = root.querySelector("[type=submit]");
const summary = root.querySelector("#iv-summary");
const list = summary.querySelector("ul");
const live = root.querySelector("[data-ap-live]");
const done = root.querySelector("[data-ap-done]");
// typed: something was typed since the page loaded. shown: what the line under it says now.
const state = new Map(fields.map((input) => [input, { typed: false, shown: null }]));
const msgOf = (input) => root.querySelector(`#${input.id}-msg`);
const timers = new Set();
function later(fn) {
const t = setTimeout(() => { timers.delete(t); fn(); }, ANNOUNCE_DELAY);
timers.add(t);
}
function say(region, text) {
region.textContent = "";
if (text) later(() => { region.textContent = text; });
}
function describedBy(input, id, on) {
const ids = (input.getAttribute("aria-describedby") || "").split(/\s+/).filter((x) => x && x !== id);
if (on) ids.push(id);
if (ids.length) input.setAttribute("aria-describedby", ids.join(" "));
else input.removeAttribute("aria-describedby");
}
/** tone: "bad", "ok" or null (an empty line). */
function show(input, tone, text) {
const line = msgOf(input);
const s = state.get(input);
s.shown = tone;
line.querySelector("[data-ap-text]").textContent = tone ? text : "";
line.querySelector("[data-ap-prefix]").hidden = tone !== "bad";
if (tone) line.dataset.tone = tone;
else delete line.dataset.tone;
input.closest(".ap-inline-validation__field").dataset.state = tone || "";
if (tone === "bad") input.setAttribute("aria-invalid", "true");
else input.removeAttribute("aria-invalid");
describedBy(input, line.id, Boolean(tone));
}
/** Check one field and show the result. Returns the problem, or "". */
function check(input) {
const problem = RULES[input.name](input.value);
if (problem) show(input, "bad", problem);
else if (TICKS[input.name]) show(input, "ok", TICKS[input.name]);
else show(input, null);
return problem;
}
function onInput(event) {
const input = event.target;
const s = state.get(input);
if (!s) return;
s.typed = true;
if (done.textContent) { done.textContent = ""; delete done.parentElement.dataset.tone; }
const problem = RULES[input.name](input.value);
if (s.shown === "bad" && !problem) {
// Put right: say so straight away, and out loud.
check(input);
if (TICKS[input.name]) say(live, TICKS[input.name]);
} else if (s.shown === "ok" && problem) {
// No longer right: take the tick away, but wait for the field to be left before scolding.
show(input, null);
}
}
function onFocusout(event) {
const input = event.target;
const s = state.get(input);
if (!s || (!s.typed && !s.shown)) return; // only passed through
// Leaving for the submit button: sending checks everything, and a message
// appearing now could move the button out from under the click.
if (event.relatedTarget && event.relatedTarget === submit) return;
const wasBad = s.shown === "bad" ? msgOf(input).querySelector("[data-ap-text]").textContent : "";
const problem = check(input);
if (problem && problem !== wasBad) say(live, problem);
}
function summaryItem(input, text) {
const item = document.createElement("li");
const link = document.createElement("a");
link.href = `#${input.id}`;
link.textContent = text;
item.append(link);
return item;
}
function onSubmit(event) {
// This demo sends nothing. A real form sends here, and the server checks again.
event.preventDefault();
const problems = [];
for (const input of fields) {
const problem = check(input);
if (problem) problems.push([input, problem]);
}
say(live, "");
if (problems.length) {
list.replaceChildren(...problems.map(([input, text]) => summaryItem(input, text)));
summary.hidden = false;
done.textContent = "";
delete done.parentElement.dataset.tone;
summary.focus();
return;
}
list.replaceChildren();
summary.hidden = true;
done.parentElement.dataset.tone = "ok";
say(done, SENT);
}
// A summary link shows its field with the label above it, then moves focus there.
function onSummaryClick(event) {
const link = event.target.closest("a[href^='#']");
const input = link && root.querySelector(link.getAttribute("href"));
if (!input) return;
event.preventDefault();
input.closest(".ap-inline-validation__field").scrollIntoView({ block: "center", behavior: "auto" });
input.focus({ preventScroll: true });
}
root.addEventListener("input", onInput);
root.addEventListener("focusout", onFocusout);
root.addEventListener("submit", onSubmit);
summary.addEventListener("click", onSummaryClick);
return () => {
for (const t of timers) clearTimeout(t);
root.removeEventListener("input", onInput);
root.removeEventListener("focusout", onFocusout);
root.removeEventListener("submit", onSubmit);
summary.removeEventListener("click", onSummaryClick);
};
}
for (const root of document.querySelectorAll("[data-ap-inline-validation]")) init(root);
/* Design tokens for the pattern components. Light by default, dark when the
system asks for it; set data-theme="dark" on :root to force dark. */
:root {
--ap-radius-sm: 8px;
--ap-radius: 12px;
--ap-radius-lg: 16px;
--ap-radius-full: 999px;
--ap-font: "Instrument Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--ap-mono: "JetBrains Mono", ui-monospace, "Cascadia Mono", "SF Mono", Consolas, monospace;
--ap-ease: cubic-bezier(.2, .8, .2, 1);
--ap-duration: 180ms;
--ap-target: 44px;
--ap-bg: #F4F4F5;
--ap-surface: #FFFFFF;
--ap-surface-2: #F4F4F5;
--ap-surface-3: #E4E4E7;
--ap-border: #E4E4E7;
--ap-border-strong: #76767F;
--ap-text: #18181B;
--ap-text-2: #3F3F46;
--ap-text-3: #5E5E66;
--ap-accent: #4F46E5;
--ap-accent-hover: #4338CA;
--ap-on-accent: #FFFFFF;
--ap-accent-text: #4338CA;
--ap-accent-soft: #EEF2FF;
--ap-accent-soft-text: #3730A3;
--ap-focus: #4F46E5;
--ap-danger: #B91C1C;
--ap-danger-soft: #FEF2F2;
--ap-success: #15803D;
--ap-success-soft: #F0FDF4;
--ap-warning: #A15C07;
--ap-warning-soft: #FEFCE8;
--ap-info: #1D4ED8;
--ap-info-soft: #EFF6FF;
--ap-scrim: rgb(9 9 11 / .48);
--ap-shadow-sm: 0 1px 2px rgb(9 9 11 / .06);
--ap-shadow-md: 0 1px 2px rgb(9 9 11 / .05), 0 6px 16px -4px rgb(9 9 11 / .1);
--ap-shadow-lg: 0 2px 6px rgb(9 9 11 / .06), 0 20px 40px -12px rgb(9 9 11 / .22);
--ap-light-bg: #F4F4F5;
--ap-light-surface: #FFFFFF;
--ap-light-surface-2: #F4F4F5;
--ap-light-surface-3: #E4E4E7;
--ap-light-border: #E4E4E7;
--ap-light-border-strong: #76767F;
--ap-light-text: #18181B;
--ap-light-text-2: #3F3F46;
--ap-light-text-3: #5E5E66;
--ap-light-accent: #4F46E5;
--ap-light-accent-hover: #4338CA;
--ap-light-on-accent: #FFFFFF;
--ap-light-accent-text: #4338CA;
--ap-light-accent-soft: #EEF2FF;
--ap-light-accent-soft-text: #3730A3;
--ap-light-focus: #4F46E5;
--ap-light-danger: #B91C1C;
--ap-light-danger-soft: #FEF2F2;
--ap-light-success: #15803D;
--ap-light-success-soft: #F0FDF4;
--ap-light-warning: #A15C07;
--ap-light-warning-soft: #FEFCE8;
--ap-light-info: #1D4ED8;
--ap-light-info-soft: #EFF6FF;
--ap-light-scrim: rgb(9 9 11 / .48);
--ap-light-shadow-sm: 0 1px 2px rgb(9 9 11 / .06);
--ap-light-shadow-md: 0 1px 2px rgb(9 9 11 / .05), 0 6px 16px -4px rgb(9 9 11 / .1);
--ap-light-shadow-lg: 0 2px 6px rgb(9 9 11 / .06), 0 20px 40px -12px rgb(9 9 11 / .22);
--ap-dark-bg: #09090B;
--ap-dark-surface: #18181B;
--ap-dark-surface-2: #27272A;
--ap-dark-surface-3: #3F3F46;
--ap-dark-border: #2E2E33;
--ap-dark-border-strong: #8E8E97;
--ap-dark-text: #FAFAFA;
--ap-dark-text-2: #D4D4D8;
--ap-dark-text-3: #A1A1AA;
--ap-dark-accent: #818CF8;
--ap-dark-accent-hover: #A5B4FC;
--ap-dark-on-accent: #0C0A1F;
--ap-dark-accent-text: #A5B4FC;
--ap-dark-accent-soft: #1E1B4B;
--ap-dark-accent-soft-text: #C7D2FE;
--ap-dark-focus: #A5B4FC;
--ap-dark-danger: #F87171;
--ap-dark-danger-soft: #2A1215;
--ap-dark-success: #4ADE80;
--ap-dark-success-soft: #0F2A1A;
--ap-dark-warning: #FACC15;
--ap-dark-warning-soft: #2A2410;
--ap-dark-info: #60A5FA;
--ap-dark-info-soft: #0F1D33;
--ap-dark-scrim: rgb(0 0 0 / .62);
--ap-dark-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-dark-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-dark-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ap-bg: #09090B;
--ap-surface: #18181B;
--ap-surface-2: #27272A;
--ap-surface-3: #3F3F46;
--ap-border: #2E2E33;
--ap-border-strong: #8E8E97;
--ap-text: #FAFAFA;
--ap-text-2: #D4D4D8;
--ap-text-3: #A1A1AA;
--ap-accent: #818CF8;
--ap-accent-hover: #A5B4FC;
--ap-on-accent: #0C0A1F;
--ap-accent-text: #A5B4FC;
--ap-accent-soft: #1E1B4B;
--ap-accent-soft-text: #C7D2FE;
--ap-focus: #A5B4FC;
--ap-danger: #F87171;
--ap-danger-soft: #2A1215;
--ap-success: #4ADE80;
--ap-success-soft: #0F2A1A;
--ap-warning: #FACC15;
--ap-warning-soft: #2A2410;
--ap-info: #60A5FA;
--ap-info-soft: #0F1D33;
--ap-scrim: rgb(0 0 0 / .62);
--ap-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
}
:root[data-theme="dark"] {
--ap-bg: #09090B;
--ap-surface: #18181B;
--ap-surface-2: #27272A;
--ap-surface-3: #3F3F46;
--ap-border: #2E2E33;
--ap-border-strong: #8E8E97;
--ap-text: #FAFAFA;
--ap-text-2: #D4D4D8;
--ap-text-3: #A1A1AA;
--ap-accent: #818CF8;
--ap-accent-hover: #A5B4FC;
--ap-on-accent: #0C0A1F;
--ap-accent-text: #A5B4FC;
--ap-accent-soft: #1E1B4B;
--ap-accent-soft-text: #C7D2FE;
--ap-focus: #A5B4FC;
--ap-danger: #F87171;
--ap-danger-soft: #2A1215;
--ap-success: #4ADE80;
--ap-success-soft: #0F2A1A;
--ap-warning: #FACC15;
--ap-warning-soft: #2A2410;
--ap-info: #60A5FA;
--ap-info-soft: #0F1D33;
--ap-scrim: rgb(0 0 0 / .62);
--ap-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
WCAG 2.2
What it meets
The success criteria this pattern takes care of, and how.
-
1.3.1 Info and Relationships
Level A
Each label is tied to its input, and the hint and the message join its aria-describedby, so they are read with the field rather than only shown beside it.
-
1.3.5 Identify Input Purpose
Level AA
Name, email, username and new password carry autocomplete tokens, so browsers and password managers can fill them in.
-
1.4.1 Use of Color
Level A
A problem shows an icon, a message and a thick edge; a tick shows an icon and words. Neither relies on red or green.
-
3.2.2 On Input
Level A
Leaving a field never moves focus or sends anything: the message appears in its own line under the field.
-
3.3.1 Error Identification
Level A
A field with a problem is marked aria-invalid and gets its message in text, beside it and in the summary.
-
3.3.3 Error Suggestion
Level AA
Every message says what to do, with an example where a format matters, like [email protected].
-
4.1.3 Status Messages
Level AA
A new error, an error put right and a sent form are announced from live regions, without moving focus.
Usage
When to use it
Use it
- Fields with a format or a rule people can miss without knowing: email addresses, usernames, passwords.
- Long forms, where finding a slip in the first field only at the end means scrolling back up.
Use something else
- Short forms of two or three fields: checking when the form is sent is simpler and just as quick.
- Free text with no format, like a name or a message: a tick there tells people nothing.
- Checks that need the server on every key press; check when the field is left, or on submit.
Common failures
How it usually goes wrong
Errors while the field is first being typed
Calling an email address wrong after one letter scolds people for a field they have not finished. Here a field is first checked when it is left.
Errors on fields people only tabbed through
Tabbing ahead to see what a form asks is not a mistake. A field that was never typed in stays quiet until the form is sent.
Success shown by a green border alone
Color alone tells nothing to people who cannot see it. Each tick here comes with words, and each error with an icon and a message.
A message that is not tied to its field
Text beside the input but outside its aria-describedby is never read when the field is focused. The script adds the message's id when it appears and removes it when it goes.
No summary when the form is sent
On a long form or a phone, inline messages scroll out of sight. Sending still lists every problem at the top and moves focus there.
A layout that jumps under the pointer
A message that pushes the button down as a field loses focus can make a click land on nothing. Each field keeps a line for its message, and leaving a field for the button skips the check, since sending checks everything.
Notes
Building it
- Reward early, punish late: once a field has been checked, an error clears the moment it is fixed, but a new error waits until the field is left.
- One polite live region speaks for the fields: it announces a new error when a field is left and the tick's words when an error is put right. Ticks on fields that were right all along stay silent.
- Full name gets no tick, because a name has no format to get right; it only says so when it is empty.
- The summary records the last attempt to send. As in the form errors pattern, it changes only when the form is sent again, so nothing above the field moves while someone types.
- The taken usernames here are a fixed list. A real check asks the server when the field is left, not on every key press, and the server checks everything again on submit.
Sources: WAI Tutorials: Validating input · WAI Tutorials: User notifications · Understanding SC 3.3.1: Error Identification
Checked with axe in light and dark themes, at desktop and phone widths, and by keyboard. Not yet tested with every screen reader and browser pair. Report a correction