Section 2 · Semantic HTML First
Accessible Forms
20 min4 min readCode playground
Lesson at a glance
Build a checkout form where every field has a label, related options are grouped, browsers can autofill, and errors are announced and fixable without sight.
- 1Every field gets a label
- 2Group related choices
- 3Help people and browsers fill it in
- 4Errors people can find and fix
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Ticketly loses a quarter of its customers at checkout, and the analytics can’t say why. Run the form with a screen reader and the reason is obvious: the fields announce “edit text, edit text, edit text”, the ticket-type radios announce “Standing, radio button” with no question, and when payment fails a red border appears that nobody without sight can perceive. Forms are where accessibility turns directly into money.
Every field gets a label
A <label> connected to an input does three things: it gives the field its accessible name, it shows sighted users what to type even after they start typing, and it makes the label text clickable, which enlarges the target.
<label for="full-name">Full name</label>
<input id="full-name" name="fullName" autocomplete="name">
<label>
Email
<input type="email" name="email" autocomplete="email" required>
</label>Both forms work: for pointing at an id, or the input nested inside the label. You can confirm the link in the console: document.querySelector('#full-name').labels.length should be 1.
Group related choices
Radio buttons and checkboxes have two layers of meaning: each option, and the question they answer together. <fieldset> and <legend> carry the question:
<fieldset>
<legend>Ticket type</legend>
<label><input type="radio" name="ticket" value="standing"> Standing</label>
<label><input type="radio" name="ticket" value="seated"> Seated</label>
<label><input type="radio" name="ticket" value="vip"> VIP lounge</label>
</fieldset>When focus enters the group, NVDA reads “Ticket type, grouping, Standing, radio button, not checked, 1 of 3”. Use the same pattern for a billing address block or a set of “notify me by” checkboxes.
Help people and browsers fill it in
Two attributes save a lot of typing, which matters most to people with motor or memory impairments.
The right type brings up the right keyboard on phones and gives basic validation: type="email", type="tel", inputmode="numeric" for a card number.
The autocomplete attribute declares what the field is for, so the browser can fill it from saved data. WCAG 1.3.5 Identify Input Purpose (AA) requires it for fields about the user. Common tokens: name, given-name, family-name, email, tel, street-address, postal-code, country, cc-name, cc-number, cc-exp, cc-csc, username, current-password, new-password.
Mark required fields with the required attribute, which screen readers announce, and with visible text such as “(required)”, not only an asterisk. And don’t ask twice: if the user typed their email at step 1, prefill it at step 3 (3.3.7 Redundant Entry).
Errors people can find and fix
This is where most forms fail. A good error has four parts, and the diagram shows how they connect:
- Say which field is wrong, in text (3.3.1 Error Identification).
- Say how to fix it (3.3.3 Error Suggestion): “Enter an email address like name@example.com”, not “Invalid input”.
- Connect it programmatically:
aria-invalid="true"on the field andaria-describedbypointing at the message. - Move focus to the first invalid field (or to an error summary at the top for long forms) so the user lands on the problem.
<label for="email">Email</label>
<input id="email" type="email" autocomplete="email" required>
<p id="email-error" class="error" hidden></p>const form = document.querySelector('#checkout');
const email = form.querySelector('#email');
const error = form.querySelector('#email-error');
form.addEventListener('submit', (event) => {
if (email.value.includes('@')) {
email.removeAttribute('aria-invalid');
email.removeAttribute('aria-describedby');
error.hidden = true;
return;
}
event.preventDefault();
error.textContent = 'Enter an email address like name@example.com';
error.hidden = false;
email.setAttribute('aria-invalid', 'true');
email.setAttribute('aria-describedby', 'email-error');
email.focus();
});The form uses novalidate in Ticketly’s markup (<form id="checkout" novalidate>) so this custom message replaces the browser’s built-in bubble, which varies between browsers and disappears quickly. That is a common choice; keeping native validation is also fine if you are happy with its messages.
For a long form with several errors, add an error summary at the top: a heading such as “There are 2 problems”, followed by a list of links, each pointing to the field it describes (<a href="#email">Enter an email address</a>). Move focus to the summary after a failed submit. Users hear how many problems there are, and each link takes them straight to a field.
Validate on submit or when the user leaves a field, not on every keystroke. Shouting “invalid email” while someone is still typing the first letters is noise for everyone and a flood of announcements for screen reader users.
Ticketly’s forms now speak clearly. The last piece of Section 2 covers content that isn’t text at all: images, icons and the data tables on the pricing page.
Key takeaways
- Every input needs a programmatic label; a placeholder disappears on typing and is not a reliable name.
- Group radio buttons and checkboxes in a fieldset whose legend asks the question.
- Autocomplete tokens such as name, email and cc-number let browsers fill fields and satisfy 1.3.5 Identify Input Purpose.
- On an error, set aria-invalid, connect the message with aria-describedby, describe how to fix it, and move focus to the first invalid field.