Forms are where people sign up, pay, apply and complain. They are also where many accessibility barriers stop people completely. An inaccessible form does not just annoy users; it stops them reaching the service.
This lesson covers labelling, instructions and errors. The HTML Complete Course covers the form controls themselves.
The rules
| Success criterion | Level | In short |
|---|---|---|
| 1.3.1 Info and Relationships | A | Labels, groups and required states are in the code, not only visual |
| 1.3.5 Identify Input Purpose | AA | Fields about the user have the right autocomplete value |
| 3.3.1 Error Identification | A | Errors are identified and described in text |
| 3.3.2 Labels or Instructions | A | Fields have labels or instructions where input is required |
| 3.3.3 Error Suggestion | AA | Error messages suggest a fix when one is known |
| 3.3.4 Error Prevention (Legal, Financial, Data) | AA | Important submissions can be reviewed, corrected or reversed |
| 3.3.7 Redundant Entry | A | Do not ask for the same information twice in one process |
| 4.1.2 Name, Role, Value | A | Every control has a name and its state is exposed |
Labels and hints
Every field needs a visible label. Put hints in a separate element and connect them with aria-describedby, so screen readers read them when the field gains focus.
<div class="field">
<label for="dob">Date of birth</label>
<p id="dob-hint" class="hint">For example, 27 3 1990</p>
<input id="dob" name="dob" inputmode="numeric" autocomplete="bday" aria-describedby="dob-hint">
</div>
Place hints between the label and the field, so sighted users read them before typing and screen magnifier users see them next to the field.
Required fields
Show "required" or "optional" in words, and mark required fields in code with the required attribute.
<label for="name">Full name (required)</label>
<input id="name" name="name" autocomplete="name" required>
If most fields are required, it is clearer to mark only the optional ones: "Phone number (optional)". If you use an asterisk, explain it at the top of the form.
Group related controls
Radio buttons and checkboxes that answer one question belong in a fieldset with a legend. Screen readers announce the legend when users enter the group, so "Yes" and "No" make sense.
<fieldset>
<legend>How should we contact you?</legend>
<div>
<input type="radio" id="contact-email" name="contact" value="email">
<label for="contact-email">Email</label>
</div>
<div>
<input type="radio" id="contact-phone" name="contact" value="phone">
<label for="contact-phone">Phone</label>
</div>
<div>
<input type="radio" id="contact-post" name="contact" value="post">
<label for="contact-post">Post</label>
</div>
</fieldset>
Use the same pattern for groups of related text fields, such as the parts of an address.
Help the browser fill fields in: 1.3.5
The autocomplete attribute tells browsers and assistive technology what a field is for. Browsers can then fill it in, which helps people with memory, motor and cognitive disabilities, and some tools can show familiar icons next to the fields.
<label for="email">Email address</label>
<input id="email" name="email" type="email" autocomplete="email">
<label for="tel">Phone number (optional)</label>
<input id="tel" name="tel" type="tel" autocomplete="tel">
Use the exact tokens from the HTML specification, such as given-name, family-name, street-address, postal-code and cc-number. The HTML Complete Course lists them in its autocomplete lesson.
Errors that people can find and fix
Good error handling has four parts.
- Say what is wrong and how to fix it, in text. "Enter your email address" or "Enter an email address like name@example.com", not "Invalid input".
- Connect the message to the field with
aria-describedby, and mark the field witharia-invalid="true". - Show a summary at the top of the form, with links to each field, and move focus to it.
- Keep what the user typed, so they only fix the field that is wrong.
Write error messages as instructions. Describe what the user should do, not what they did wrong: "Enter a date in the past" rather than "Date is invalid".
A complete example
This form validates on submit, shows an error summary and inline messages, and moves focus to the summary. It works without the script too, because the server should always validate again.
<form id="signup" action="/signup" method="post" novalidate>
<div id="error-summary" class="error-summary" tabindex="-1" hidden>
<h2>There is a problem</h2>
<ul id="error-list"></ul>
</div>
<div class="field">
<label for="full-name">Full name</label>
<p id="full-name-error" class="error" hidden></p>
<input id="full-name" name="full-name" autocomplete="name" required>
</div>
<div class="field">
<label for="email">Email address</label>
<p id="email-hint" class="hint">We will only use this to send your receipt.</p>
<p id="email-error" class="error" hidden></p>
<input id="email" name="email" type="email" autocomplete="email"
aria-describedby="email-hint" required>
</div>
<button type="submit">Create account</button>
</form>
<script>
const form = document.getElementById('signup');
const summary = document.getElementById('error-summary');
const list = document.getElementById('error-list');
const rules = {
'full-name': (v) => (v.trim() === '' ? 'Enter your full name' : ''),
email: (v) => {
if (v.trim() === '') return 'Enter your email address';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) {
return 'Enter an email address in the correct format, like name@example.com';
}
return '';
},
};
function setError(input, message) {
const error = document.getElementById(`${input.id}-error`);
const hintId = input.id === 'email' ? 'email-hint' : '';
error.textContent = message ? `Error: ${message}` : '';
error.hidden = !message;
if (message) {
input.setAttribute('aria-invalid', 'true');
input.setAttribute('aria-describedby', `${hintId} ${error.id}`.trim());
} else {
input.removeAttribute('aria-invalid');
if (hintId) {
input.setAttribute('aria-describedby', hintId);
} else {
input.removeAttribute('aria-describedby');
}
}
}
form.addEventListener('submit', (event) => {
list.replaceChildren();
let hasErrors = false;
for (const [id, check] of Object.entries(rules)) {
const input = document.getElementById(id);
const message = check(input.value);
setError(input, message);
if (message) {
hasErrors = true;
const item = document.createElement('li');
const link = document.createElement('a');
link.href = `#${id}`;
link.textContent = message;
item.append(link);
list.append(item);
}
}
if (hasErrors) {
event.preventDefault();
summary.hidden = false;
document.title = `Error: ${document.title.replace(/^Error: /, '')}`;
summary.focus();
} else {
summary.hidden = true;
}
});
list.addEventListener('click', (event) => {
const link = event.target.closest('a');
if (!link) return;
event.preventDefault();
document.querySelector(link.getAttribute('href')).focus();
});
</script>
What each part does:
novalidateturns off the browser's built-in error bubbles, which are inconsistent across browsers and screen readers, so the form can show its own accessible messages. Therequiredattributes still expose the required state.- The summary is a heading plus links. Moving focus to it tells screen reader users that submission failed, and the links take everyone straight to each field.
- Each error sits between the label and the input and is joined to it with
aria-describedby, alongside the hint. The prefix "Error:" makes the meaning clear without colour. aria-invalid="true"makes screen readers announce "invalid entry".- Prefixing the page title with "Error:" helps when the same pattern runs on the server after a full page reload.
Avoid validating on every keystroke or when a field loses focus for the first time. Messages that appear while someone is still typing are distracting and can interrupt screen reader output. Validate on submit, then clear each error once it is fixed.
Protect important submissions: 3.3.4
For legal commitments, payments and changes to stored data, give users at least one of: a way to reverse the submission, a check for errors with a chance to correct them, or a review page to confirm before submitting. A "Check your answers" page is a common and effective solution.
Do not ask twice: 3.3.7
If a user already gave information earlier in the same process, fill it in or let them select it. A "Billing address is the same as delivery address" checkbox is the classic example.
Summary
- Give every field a visible
label; connect hints and errors witharia-describedby. - Write "required" or "optional" in words and use the
requiredattribute. - Group radio buttons, checkboxes and related fields in
fieldsetwithlegend. - Add correct
autocompletevalues for personal data (1.3.5). - Describe errors in text with a fix (3.3.1, 3.3.3), mark fields
aria-invalid, and show a linked summary that receives focus. - Let people review important submissions (3.3.4) and never make them enter the same data twice (3.3.7).