Accessible Form Error Summary
Validate a signup form with a focusable error summary, linked error messages, aria-invalid states, and a polite success announcement.
Code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Signup with an accessible error summary</title>
<style>
body {
margin: 0;
padding: 2rem;
font-family: system-ui, sans-serif;
}
form {
max-width: 28rem;
display: grid;
gap: 1.25rem;
}
.field {
display: grid;
gap: 0.35rem;
}
input {
font: inherit;
padding: 0.5rem;
border: 1px solid gray;
border-radius: 0.35rem;
}
input:focus-visible,
button:focus-visible,
.error-summary:focus-visible {
outline: 3px solid #4f46e5;
outline-offset: 2px;
}
.error-summary {
border: 2px solid #b91c1c;
border-radius: 0.5rem;
padding: 1rem;
}
.error-summary h2 {
margin-top: 0;
font-size: 1.15rem;
}
.error-summary a {
color: inherit;
}
.field-error {
margin: 0;
color: #b91c1c;
}
input[aria-invalid="true"] {
border-color: #b91c1c;
}
fieldset {
border: 0;
padding: 0;
display: grid;
gap: 0.35rem;
}
</style>
</head>
<body>
<main>
<h1>Create your account</h1>
<form id="signup" novalidate>
<div class="error-summary" id="error-summary" role="alert" tabindex="-1" hidden>
<h2>There is a problem</h2>
<ul id="error-list"></ul>
</div>
<div class="field">
<label for="email">Email address</label>
<input id="email" name="email" type="email" autocomplete="email" required aria-describedby="email-error">
<p class="field-error" id="email-error" hidden></p>
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="new-password" minlength="8" required aria-describedby="password-error">
<p class="field-error" id="password-error" hidden></p>
</div>
<fieldset aria-describedby="plan-error">
<legend>Plan</legend>
<label><input type="radio" name="plan" value="starter" id="plan-starter" required> Starter</label>
<label><input type="radio" name="plan" value="pro" id="plan-pro"> Pro</label>
<p class="field-error" id="plan-error" hidden></p>
</fieldset>
<div class="field">
<label for="terms">
<input id="terms" name="terms" type="checkbox" required aria-describedby="terms-error">
I accept the terms of service
</label>
<p class="field-error" id="terms-error" hidden></p>
</div>
<button type="submit">Create account</button>
<p id="status" role="status" aria-live="polite"></p>
</form>
</main>
<script>
const form = document.querySelector('#signup');
const summary = document.querySelector('#error-summary');
const errorList = document.querySelector('#error-list');
const status = document.querySelector('#status');
const email = document.querySelector('#email');
const password = document.querySelector('#password');
const planInputs = [...document.querySelectorAll('input[name="plan"]')];
const terms = document.querySelector('#terms');
const fields = [
{
input: email,
error: document.querySelector('#email-error'),
validate() {
const value = email.value.trim();
if (!value) return 'Enter your email address.';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return 'Enter an email address in the format [email protected].';
}
return '';
}
},
{
input: password,
error: document.querySelector('#password-error'),
validate() {
if (!password.value) return 'Enter a password.';
if (password.value.length < 8) return 'Use at least 8 characters for your password.';
return '';
}
},
{
inputs: planInputs,
input: planInputs[0],
error: document.querySelector('#plan-error'),
validate() {
return document.querySelector('input[name="plan"]:checked') ? '' : 'Choose a plan.';
}
},
{
input: terms,
error: document.querySelector('#terms-error'),
validate() {
return terms.checked ? '' : 'Accept the terms to continue.';
}
}
];
function targetsFor(field) {
return field.inputs ?? [field.input];
}
function clearError(field) {
field.error.textContent = '';
field.error.hidden = true;
for (const target of targetsFor(field)) {
target.removeAttribute('aria-invalid');
}
if (!fields.some((candidate) => !candidate.error.hidden)) {
summary.hidden = true;
errorList.replaceChildren();
}
}
for (const field of fields) {
for (const target of targetsFor(field)) {
target.addEventListener('input', () => clearError(field));
target.addEventListener('change', () => clearError(field));
}
}
form.addEventListener('submit', (event) => {
event.preventDefault();
errorList.replaceChildren();
const invalid = [];
for (const field of fields) {
const message = field.validate();
field.error.textContent = message;
field.error.hidden = message === '';
for (const target of targetsFor(field)) {
if (message) target.setAttribute('aria-invalid', 'true');
else target.removeAttribute('aria-invalid');
}
if (message) invalid.push({ field, message });
}
if (invalid.length > 0) {
for (const { field, message } of invalid) {
const item = document.createElement('li');
const link = document.createElement('a');
link.href = '#' + targetsFor(field)[0].id;
link.textContent = message;
link.addEventListener('click', (clickEvent) => {
clickEvent.preventDefault();
targetsFor(field)[0].focus();
});
item.append(link);
errorList.append(item);
}
summary.hidden = false;
summary.focus();
status.textContent = '';
return;
}
summary.hidden = true;
status.textContent = 'Account created. Check your inbox to confirm your email.';
form.reset();
});
</script>
</body>
</html>Report a problem with this exampleOpens a pre-filled issue on GitHub.