EduFlow

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.

  1. 1Every field gets a label
  2. 2Group related choices
  3. 3Help people and browsers fill it in
  4. 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.

ticketly/checkout.html
<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:

HTML
<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:

A label element gives the input its name through the for attribute. The input carries aria-invalid true, and aria-describedby points to a hint and an error message, which become its description. Focus moves to the input after a failed submit. <label for> <input> aria-invalid="true" Hint text Error message name described by focus lands here on error
The parts of an accessible field
  1. Say which field is wrong, in text (3.3.1 Error Identification).
  2. Say how to fix it (3.3.3 Error Suggestion): “Enter an email address like name@example.com”, not “Invalid input”.
  3. Connect it programmatically: aria-invalid="true" on the field and aria-describedby pointing at the message.
  4. 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.
HTML
<label for="email">Email</label>
<input id="email" type="email" autocomplete="email" required>
<p id="email-error" class="error" hidden></p>
ticketly/checkout.js
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.

Further reading

Check your understanding

4 questions

Question 1 of 4

What does a screen reader announce when focus reaches this field?

HTML
<input type="email" id="email" placeholder="Email">
Question 2 of 4

Ticketly asks “Ticket type” with three radio buttons. How should the question be marked up?

Question 3 of 4

After a failed submit, which change helps a screen reader user most?

Question 4 of 4

Which autocomplete value is correct for the cardholder’s name on Ticketly’s payment step?

Practice

Code playground

Repair Ticketly's checkout form

The checkout form relies on placeholders, floats a question above unlabelled radios, and shows an email error that only sighted users notice. Fix it:

  1. Give #full-name, #email and both radios a real <label> (“Full name”, “Email”, “Standing”, “Seated”).
  2. Group the radios in a <fieldset> whose <legend> is “Ticket type”.
  3. Add autocomplete="name" and autocomplete="email", and make the email field type="email".
  4. In the script, when the email is invalid: set aria-invalid="true", point aria-describedby at #email-error, and move focus to the email field. When it becomes valid, remove both attributes.
<form id="checkout" novalidate>
  <input id="full-name" name="fullName" placeholder="Full name">
  <input id="email" name="email" type="text" placeholder="Email">
  <p id="email-error" class="error" hidden></p>

  <p class="question">Ticket type</p>
  <input type="radio" name="ticket" value="standing" id="t-standing"> Standing
  <input type="radio" name="ticket" value="seated" id="t-seated"> Seated

  <button id="pay">Pay now</button>
</form>Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Preview

Console

(no output)

Checks

  • All four inputs have labels with the right text
  • The radios sit in a fieldset whose first child is the legend “Ticket type”
  • Name and email have autocomplete tokens, and email is type=“email”
  • An invalid email sets aria-invalid and links the visible error with aria-describedby
  • Focus moves to the email field after the failed attempt
  • A valid email removes aria-invalid and hides the error