Difficulty: Intermediate
Accessible forms ensure that all users, including those using screen readers, keyboard navigation, or other assistive technologies, can successfully complete and submit forms. Accessibility is not an optional enhancement -- it is a fundamental requirement of web development. Many countries have laws (ADA in the US, European Accessibility Act in the EU) that require websites to be accessible, and inaccessible forms exclude a significant portion of users.
The <label> element is the cornerstone of form accessibility. Every form control must have an associated label that describes what the field expects. There are two ways to associate a label: explicit association using the for attribute (which references the input's id) and implicit association by wrapping the input inside the label element. The explicit approach is generally preferred because it works regardless of DOM structure and gives you more styling flexibility. When a label is properly associated, clicking the label text focuses the corresponding input, which also improves usability for all users.
The <fieldset> element groups related form controls together, and the <legend> element provides a caption for that group. This pairing is essential for radio button groups and checkbox groups because individual labels describe each option, but the group needs an overall question or category label. Without <fieldset>/<legend>, a screen reader user hearing 'Red' as a radio button label has no context about what 'Red' refers to. With a legend of 'Favorite Color', the screen reader announces 'Favorite Color group, Red radio button'.
The aria-describedby attribute links a form control to additional descriptive text elsewhere in the DOM. This is commonly used for help text, format hints, and error messages. When a screen reader focuses on an input with aria-describedby, it reads the input's label first and then the referenced description. For error messages, use aria-live="polite" on the error container so that dynamically added error messages are announced without the user needing to re-focus the field.
For error handling, there are several best practices: display error messages near the field they relate to (typically below), use aria-invalid="true" on fields with errors, associate error messages with the field using aria-describedby, use a visible error summary at the top of the form for longer forms, and ensure errors are announced by placing them in an aria-live region. The color of error messages should not be the only indicator -- always include text or an icon to convey meaning, since some users cannot perceive color differences.
<!-- Explicit association: for/id -->
<label for="email">Email Address:</label>
<input type="email" id="email" name="email" required>
<!-- Implicit association: wrapping -->
<label>
Phone Number:
<input type="tel" name="phone">
</label>
<!-- WRONG: No label association -->
<p>Username:</p>
<input type="text" name="username">
<!-- The text "Username:" is NOT associated with the input -->
Explicit labels use for/id to create the association. Implicit labels wrap the input inside the label element. The third example shows a common mistake: adjacent text is not a label. Screen readers will announce the input as 'unlabelled text field'.
<form>
<fieldset>
<legend>Shipping Address</legend>
<label for="street">Street:</label>
<input type="text" id="street" name="street" required>
<label for="city">City:</label>
<input type="text" id="city" name="city" required>
<label for="zip">ZIP Code:</label>
<input type="text" id="zip" name="zip"
pattern="[0-9]{5,6}" required>
</fieldset>
<fieldset>
<legend>Delivery Speed</legend>
<label>
<input type="radio" name="delivery" value="standard" checked>
Standard (5-7 days)
</label>
<label>
<input type="radio" name="delivery" value="express">
Express (2-3 days)
</label>
<label>
<input type="radio" name="delivery" value="overnight">
Overnight (next day)
</label>
</fieldset>
</form>
Each <fieldset> groups related controls and <legend> provides the group label. For the radio buttons, the legend 'Delivery Speed' gives context to the individual option labels. A screen reader user would hear 'Delivery Speed group, Standard 5-7 days radio button'.
<form>
<label for="password">Password:</label>
<input type="password" id="password" name="password"
required minlength="8"
aria-describedby="pwd-help pwd-error"
aria-invalid="false">
<p id="pwd-help" class="help-text">
Must be at least 8 characters with one number and one uppercase letter.
</p>
<p id="pwd-error" class="error-text"
role="alert" aria-live="polite">
</p>
</form>
<script>
const pwd = document.getElementById('password');
const error = document.getElementById('pwd-error');
pwd.addEventListener('blur', function() {
if (pwd.value.length > 0 && pwd.value.length < 8) {
error.textContent = 'Password is too short.';
pwd.setAttribute('aria-invalid', 'true');
} else {
error.textContent = '';
pwd.setAttribute('aria-invalid', 'false');
}
});
</script>
aria-describedby references both the help text and error message. The screen reader reads both when the field is focused. The error container uses role="alert" and aria-live="polite" so that dynamically inserted error messages are announced. aria-invalid indicates whether the field currently has an error.
<form id="reg-form" novalidate>
<div id="error-summary" role="alert"
aria-live="assertive" hidden>
<h2>Please fix the following errors:</h2>
<ul id="error-list"></ul>
</div>
<label for="name">Name:</label>
<input type="text" id="name" name="name" required
aria-describedby="name-error">
<span id="name-error" class="field-error"></span>
<label for="reg-email">Email:</label>
<input type="email" id="reg-email" name="email" required
aria-describedby="email-error">
<span id="email-error" class="field-error"></span>
<button type="submit">Register</button>
</form>
<script>
document.getElementById('reg-form')
.addEventListener('submit', function(e) {
e.preventDefault();
const errors = [];
const name = document.getElementById('name');
const email = document.getElementById('reg-email');
if (!name.value.trim()) {
errors.push({ field: 'name', message: 'Name is required' });
document.getElementById('name-error').textContent = 'Name is required';
name.setAttribute('aria-invalid', 'true');
}
if (!email.value.trim()) {
errors.push({ field: 'reg-email', message: 'Email is required' });
document.getElementById('email-error').textContent = 'Email is required';
email.setAttribute('aria-invalid', 'true');
}
const summary = document.getElementById('error-summary');
const list = document.getElementById('error-list');
if (errors.length > 0) {
list.innerHTML = errors.map(e =>
'<li><a href="#' + e.field + '">' + e.message + '</a></li>'
).join('');
summary.hidden = false;
summary.focus();
} else {
summary.hidden = true;
console.log('Form submitted!');
}
});
</script>
An error summary at the top of the form lists all errors with links that jump to the offending field. This pattern is essential for long forms where inline errors may be out of view. The summary uses role="alert" and aria-live="assertive" to be announced immediately by screen readers.
label, fieldset, legend, aria-describedby, Error Messages, Accessible Forms