Accessible Forms and Error Handling
1. Introduction
Forms are one of the most challenging areas of web accessibility. They require correct labelling, logical grouping, clear instructions, and informative error messages โ all communicated to sighted users, keyboard navigators, and screen reader users simultaneously. This lesson brings together everything you have learned about forms and accessibility into a production-ready, WCAG 2.1 Level AA conformant pattern.
2. Concept
Accessible Form Requirements
| Requirement | WCAG Criterion | Implementation |
|---|---|---|
| Every control has a visible label | 1.3.1 (A), 2.4.6 (AA) | <label for> or aria-label |
| Required fields identified | 3.3.2 (A) | Visual indicator + aria-required=”true” |
| Instructions before fields | 3.3.2 (A) | aria-describedby pointing to hint text |
| Error messages identify the field | 3.3.1 (A) | Error text adjacent to field + role=”alert” |
| Suggestions provided after error | 3.3.3 (AA) | Specific correction guidance in error message |
| Focus moved to first error | 2.4.3 (A) | JavaScript .focus() on form submission |
aria-describedby. A visual error message next to the field is insufficient โ the screen reader must announce the error when the user focuses the invalid field.autocomplete attributes on all personal information fields. This is a WCAG 1.3.5 Level AA requirement and helps users with cognitive disabilities and motor impairments who struggle with repetitive data entry.placeholder as a substitute for a <label>. Placeholder text disappears when typing begins, fails contrast requirements in most browsers, and is not reliably announced as a field label by all screen readers.3. Basic Example
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Accessible Form Demo</title>
<style>
.field { margin-bottom: 1.5rem; }
label { display: block; font-weight: 600; margin-bottom: 0.25rem; }
.error { color: #cc0000; font-size: 0.875rem; margin-top: 0.25rem; }
input[aria-invalid="true"] { border: 2px solid #cc0000; }
:focus-visible { outline: 3px solid #0070f3; outline-offset: 2px; }
</style>
</head>
<body>
<h1>Create Account</h1>
<!-- Error summary โ shown on failed submission -->
<div id="err-summary" role="alert" aria-live="assertive"
aria-labelledby="err-title" tabindex="-1" hidden>
<h2 id="err-title">There are errors in this form</h2>
<ul id="err-list"></ul>
</div>
<form id="signup" novalidate>
<div class="field">
<label for="fname">
Full Name <span aria-hidden="true">*</span>
</label>
<input type="text" id="fname" name="full_name"
autocomplete="name" required
aria-required="true"
aria-describedby="fname-err"
aria-invalid="false"
>
<p id="fname-err" class="error" role="alert" hidden>
Full Name is required. Enter your first and last name.
</p>
</div>
<div class="field">
<label for="femail">
Email <span aria-hidden="true">*</span>
</label>
<p id="femail-hint" style="font-size:.85rem;color:#555">We will send a confirmation to this address.</p>
<input type="email" id="femail" name="email"
autocomplete="email" required
aria-required="true"
aria-describedby="femail-hint femail-err"
aria-invalid="false"
>
<p id="femail-err" class="error" role="alert" hidden>
Enter a valid email address, e.g. name@example.com.
</p>
</div>
<button type="submit">Create Account</button>
</form>
<script>
document.getElementById('signup').addEventListener('submit', function(e) {
e.preventDefault();
var errors = [];
var fname = document.getElementById('fname');
var femail = document.getElementById('femail');
var fnErr = document.getElementById('fname-err');
var fmErr = document.getElementById('femail-err');
if (!fname.value.trim()) {
fname.setAttribute('aria-invalid', 'true');
fnErr.hidden = false;
errors.push({id: 'fname', msg: 'Full Name: This field is required'});
} else {
fname.setAttribute('aria-invalid', 'false');
fnErr.hidden = true;
}
if (!femail.value.trim() || !femail.validity.valid) {
femail.setAttribute('aria-invalid', 'true');
fmErr.hidden = false;
errors.push({id: 'femail', msg: 'Email: Enter a valid email address'});
} else {
femail.setAttribute('aria-invalid', 'false');
fmErr.hidden = true;
}
var summary = document.getElementById('err-summary');
if (errors.length) {
var list = document.getElementById('err-list');
list.innerHTML = errors.map(function(er) {
return '<li><a href="#' + er.id + '">' + er.msg + '</a></li>';
}).join('');
summary.hidden = false;
summary.focus();
} else {
summary.hidden = true;
}
});
</script>
</body>
</html>
4. How It Works
Step 1 โ Required Field Indication
The asterisk (*) is wrapped in aria-hidden="true" to prevent screen readers announcing “asterisk” for every required field. The form includes a note that “fields marked * are required” for sighted users. aria-required="true" communicates the requirement to AT users directly.
Step 2 โ Error Summary Pattern
On submission failure, show an error summary with role="alert" and move focus to it. Each error links directly to the relevant field. This follows the GOV.UK design system pattern and is the recognised best practice for accessible error handling in large forms.
Step 3 โ Inline Errors with aria-invalid
Set aria-invalid="true" on the input and unhide the associated error paragraph when validation fails. Since aria-describedby already references the error element, when the user tabs back to the input the screen reader announces: label โ “invalid” โ error description.
Step 4 โ Multiple IDs in aria-describedby
aria-describedby="femail-hint femail-err" references both hint and error. The screen reader reads them in order: label โ hint โ error. When the error element is hidden, browsers skip it automatically โ no need to update aria-describedby dynamically.
5. Real-World Example
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Accessible Login</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 400px; margin: 2rem auto; padding: 1rem; }
.field { margin-bottom: 1.25rem; }
label { display: block; font-weight: 600; margin-bottom: 0.25rem; }
input { width: 100%; padding: 0.5rem; font-size: 1rem; border: 1px solid #999; border-radius: 4px; box-sizing: border-box; }
input[aria-invalid="true"] { border-color: #b91c1c; }
.hint { font-size: 0.85rem; color: #555; margin-top: 0.2rem; }
.error-msg { font-size: 0.85rem; color: #b91c1c; margin-top: 0.25rem; }
:focus-visible { outline: 3px solid #2563eb; outline-offset: 2px; }
.btn { width: 100%; padding: 0.6rem; font-size: 1rem; background: #2563eb; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
</style>
</head>
<body>
<main>
<h1>Sign In</h1>
<div role="alert" id="login-error" aria-live="assertive" hidden
style="background:#fef2f2;border:1px solid #fca5a5;padding:.75rem;border-radius:4px;margin-bottom:1rem">
<strong>Sign in failed.</strong> Your email or password is incorrect.
</div>
<form action="/login" method="post" novalidate>
<div class="field">
<label for="lemail">Email address</label>
<input type="email" id="lemail" name="email"
autocomplete="email" spellcheck="false"
required aria-required="true"
aria-invalid="false" aria-describedby="lemail-err"
>
<p id="lemail-err" class="error-msg" role="alert" hidden>Enter your email address.</p>
</div>
<div class="field">
<label for="lpass">Password</label>
<p id="pass-hint" class="hint">Your password is case-sensitive.</p>
<input type="password" id="lpass" name="password"
autocomplete="current-password"
required aria-required="true"
aria-invalid="false" aria-describedby="pass-hint lpass-err"
>
<p id="lpass-err" class="error-msg" role="alert" hidden>Enter your password.</p>
</div>
<div class="field">
<label>
<input type="checkbox" name="remember" value="1">
Keep me signed in for 30 days
</label>
</div>
<button type="submit" class="btn">Sign In</button>
<p style="margin-top:1rem"><a href="/forgot-password">Forgot your password?</a></p>
</form>
</main>
</body>
</html>
6. Common Mistakes
❌ Error message not linked to the field
<input type="email" id="email">
<p style="color:red">Invalid email</p>
✓ Use aria-describedby and role=”alert” to link error to field
<input type="email" id="email" aria-describedby="email-err" aria-invalid="true">
<p id="email-err" role="alert">Enter a valid email, e.g. name@example.com</p>
❌ Placeholder as the only label
<input type="text" placeholder="Your full name">
✓ Always provide a persistent visible label above each input
<label for="fname">Full name</label>
<input type="text" id="fname" placeholder="e.g. Jane Smith">
7. Try It Yourself
8. Quick Reference
| Pattern | Attributes Used | WCAG Criterion |
|---|---|---|
| Visible label | <label for> | 1.3.1, 2.4.6 (A/AA) |
| Required indicator | aria-required=”true” | 3.3.2 (A) |
| Hint text | aria-describedby | 3.3.2 (A) |
| Inline error | aria-invalid=”true” + role=”alert” | 3.3.1 (A) |
| Error summary | role=”alert” + focus management | 3.3.3 (AA) |
| Autocomplete | autocomplete=”name” etc. | 1.3.5 (AA) |
| Correct input type | type=”email”, type=”tel” etc. | 1.3.5 (AA) |