In 04-01 we saw that native form controls (<input>, <select>, <textarea>, <button>) come with role, focus and keyboard operation built in. But an accessible form needs more than good controls: it needs every control to have a name, related fields to be grouped, hints and errors to reach everyone, and the focus order to be logical. Those are exactly the 3.3.x criteria ("Input Assistance") that in Module 3 we studied as a requirement and now implement.

Our practice ground is Cursalia's enrollment/registration form: name, email, phone, password, level of education, interests and terms acceptance. We'll build it piece by piece, always with correct versus incorrect examples, thinking about Marta (NVDA must read her each field's label), Hugo (needs clear, unambiguous instructions) and Lucía (traverses it entirely with the keyboard).

Contents

  1. The label: associating label and control
  2. Why the placeholder is not a label
  3. Grouping with fieldset and legend
  4. Required fields: required and aria-required
  5. Instructions and hints with aria-describedby
  6. Accessible errors (and a first look at role="alert")
  7. autocomplete and input types
  8. Focus and logical order
  9. Common mistakes and tips
  10. Exercises
  11. Conclusion

The label: associating label and control

Every form control must have an accessible label. The label is the control's name (remember 4.1.2): without it, Marta hears "edit box" without knowing what to type. There are two correct ways to associate a <label> with its control.

Option A — for / id (the most common and flexible): the label's for attribute points to the control's id.

<!-- CORRECT: for points to the id -->
<label for="email">Email</label>
<input type="email" id="email" name="email">

Option B — wrapping label: the control goes inside the <label>. No for/id needed.

<!-- CORRECT: label wraps the control -->
<label>
  Email
  <input type="email" name="email">
</label>

Both make clicking the text focus the field (a larger click area, something Lucía appreciates) and make the reader announce it with its name. The usual mistake is not associating them: loose text next to an input.

<!-- INCORRECT: the text is not associated with the field -->
<span>Email</span>
<input type="email" name="email">

Here there is no <label> (or its for doesn't match any id): Marta hears "blank edit box". If by design you can't show visible text (for example, the catalog search box with just a magnifying glass), use aria-label, which we'll cover in 04-03:

<input type="search" aria-label="Search courses">

Why the placeholder is not a label

It's tempting to add just a placeholder="Email" and skip the <label>. That's a serious mistake for several reasons:

Problem with the placeholder as a label Who it affects
Disappears when you start typing; you no longer know what the field was Hugo (memory/attention), everyone
Its contrast is usually low (light gray) and fails 1.4.3 Sofía (low vision)
Many readers don't announce it as a reliable name Marta
You can't click it to focus the field Lucía
<!-- INCORRECT: placeholder only, no label -->
<input type="email" placeholder="Email">

<!-- CORRECT: visible label; the placeholder, if any, only as a format example -->
<label for="email">Email</label>
<input type="email" id="email" placeholder="[email protected]">

Rule: the placeholder is, at most, a format hint; it never replaces the label.

Grouping with fieldset and legend

When several controls form a single conceptual group —a set of radios (pick one) or checkboxes (pick several)—, you must wrap them in a <fieldset> and give them a common title with <legend>. That way the reader announces the group ("Level of education, group") before each option, giving the context a lone radio wouldn't have.

<!-- CORRECT: radio group with fieldset/legend -->
<fieldset>
  <legend>Level of education</legend>

  <label><input type="radio" name="level" value="secondary"> Secondary</label>
  <label><input type="radio" name="level" value="degree"> University degree</label>
  <label><input type="radio" name="level" value="master"> Master's or higher</label>
</fieldset>

Marta hears: "Level of education, group. Secondary, radio button, 1 of 3". Without the fieldset, she'd hear three lone radios without knowing which question they answer.

<!-- INCORRECT: loose text groups nothing semantically -->
<p>Level of education</p>
<label><input type="radio" name="level" value="degree"> Degree</label>

The same applies to interests (checkboxes) or accepting several terms. Don't overuse fieldset for independent fields (name, email): it's only for groups.

Required fields: required and aria-required

Marking a field as required has two sides you must cover at once: the functional and the communicative.

  • required: native attribute. Activates the browser's validation and exposes the "required" state to the reader (Marta hears "required"). It's the first choice.
  • aria-required="true": only if you need to communicate the requirement without triggering the browser's native validation. Don't duplicate both without reason; required is usually enough.
  • Visual and textual indication: a red asterisk isn't enough (fails 1.4.1, "not by color/shape alone"). Explain what the asterisk means and, better still, reinforce it with text.
<!-- CORRECT: required communicated three ways: native, visual and textual -->
<p id="required-note">Fields marked with * are required.</p>

<label for="name">Full name *</label>
<input type="text" id="name" name="name" required
       aria-describedby="required-note">
<!-- INCORRECT: the requirement lives only in the color of the asterisk -->
<label for="name">Full name <span style="color:red">*</span></label>
<input type="text" id="name">   <!-- no required, no explanation of the * -->

Instructions and hints with aria-describedby

Many fields need a hint: password format, what the phone number is used for, etc. For that hint to be read alongside the field (and not left as orphan text beside it), you associate it with aria-describedby, which points to the id of the hint text. The reader reads the label first and the description afterward.

<!-- CORRECT: the hint is associated with aria-describedby -->
<label for="pass">Password</label>
<input type="password" id="pass" name="pass"
       aria-describedby="pass-hint" required>
<p id="pass-hint">At least 8 characters, with one letter and one number.</p>

Marta hears: "Password, edit box, required. At least 8 characters, with one letter and one number". Hugo reads that same clear instruction before typing, not after failing. aria-describedby can point to several ids separated by spaces (for example, the hint and the field's error message).

<!-- INCORRECT: the hint exists visually but is not associated -->
<label for="pass">Password</label>
<input type="password" id="pass">
<p>At least 8 characters, with one letter and one number.</p>  <!-- no aria-describedby: the reader doesn't link it -->

Accessible errors (and a first look at role="alert")

When the user makes a mistake, criterion 3.3.1 requires identifying the error in text (not by color alone) and describing it usefully. Best practices:

  • Explicit text, not just a red border: "The email is invalid: the @ is missing".
  • Associated with the field using aria-describedby, so the reader reads it when focusing that field.
  • Don't rely on color: add an icon or the word "Error" (this is 1.4.1, which we'll cover in depth in 04-04).
  • Moving focus to the first field with an error or summarizing the errors at the top helps Lucía and Marta.
<!-- CORRECT: error in text, associated, and not by color alone -->
<label for="email">Email *</label>
<input type="email" id="email" name="email" required
       aria-describedby="email-err" aria-invalid="true">
<p id="email-err" class="error">
  <span aria-hidden="true">⚠️</span> Error: the email must include an @.
</p>

Here aria-invalid="true" marks the field as invalid in the accessibility tree, and aria-describedby makes the message read when you focus it.

When the error appears dynamically (after clicking "Submit", without reloading the page), you also need to announce it even if focus is elsewhere. That's what live regions are for. A brief introduction: a container with role="alert" (or aria-live="assertive") makes the reader read automatically any text that appears inside it, without the user having to go looking for it.

<!-- The reader will announce the text as soon as your JS inserts it here -->
<div role="alert" id="error-summary"></div>
// On validation, you insert the message and NVDA reads it on its own:
document.getElementById('error-summary').textContent =
  'Check the form: there are 2 fields with errors.';

We stop at this introduction: the detail of aria-live, role="status" vs role="alert" and dynamic regions is covered at the markup level in 04-03, and full validation with JavaScript (when to validate, how to move focus, updating messages live) belongs to Module 5.

autocomplete and input types

Choosing the right type and adding autocomplete reduces everyone's effort, and very noticeably for Lucía (less typing), Hugo (less to remember) and anyone using voice control or a phone.

Field Correct type autocomplete Benefit
Email email email Keyboard with @, format validation, autofill
Phone tel tel Numeric keypad on mobile
Name text name Autofill of the saved name
Password (registration) password new-password The manager suggests a secure one
<!-- CORRECT: types and autocomplete that help both the browser and the person -->
<label for="email">Email</label>
<input type="email" id="email" name="email" autocomplete="email">

<label for="tel">Phone</label>
<input type="tel" id="tel" name="tel" autocomplete="tel">

autocomplete also meets criterion 1.3.5 ("Identify Input Purpose"), which helps tools that adapt or fill in fields for the user.

Focus and logical order

The order in which Lucía reaches the fields when pressing Tab must follow the form's visual and logical order. The key is that the DOM order matches the visual order: if you lay out top to bottom in the HTML, focus will flow correctly with no tricks.

  • Don't use a positive tabindex (tabindex="1", "2"...): it breaks the natural order and is a constant source of bugs. Let the DOM dictate the order.
  • A real <button type="submit"> lets you submit the form with Enter from any field.
  • After submitting with errors, moving focus to the error summary or the first failing field saves Lucía from tabbing blindly (JS implementation: Module 5).
<!-- INCORRECT: positive tabindex forces an artificial, fragile order -->
<input id="a" tabindex="3">
<input id="b" tabindex="1">
<input id="c" tabindex="2">
<!-- CORRECT: no tabindex; the DOM order is already the focus order -->
<input id="a">
<input id="b">
<input id="c">

Common Mistakes and Tips

  • Fields with no associated <label>. This is the number-one failure. Every control needs a name via for/id, a wrapping label or, as a last resort, aria-label.
  • Using the placeholder as a label. It disappears, contrasts poorly and is unreliable as a name. Always add a visible label.
  • Requirement by color only. The red asterisk isn't enough: add required and explain what the * means.
  • Hints and errors left unassociated. A hint next to a field that isn't linked with aria-describedby is invisible to the reader.
  • Errors by red border only. Marta doesn't see color: the error must be in text and be associated.
  • Radios/checkboxes without fieldset/legend. They lose the context of the question.
  • Positive tabindex. Almost never the solution; order the DOM instead.
  • Tip: fill in your entire form using only the keyboard and a reader, with your eyes closed. If you can't tell what a field asks for or where the error is, it isn't accessible yet.

Exercises

Exercise 1: give the fields a name

This fragment of the Cursalia registration doesn't associate labels. Fix it using for/id and replace the placeholder-label with a real label.

<span>Name</span>
<input type="text">
<input type="email" placeholder="Email">

Exercise 2: group the interests

Users can check several interests. Mark up this checkbox group correctly so the reader announces the group title.

<p>Interests</p>
<label><input type="checkbox" name="int" value="dev"> Development</label>
<label><input type="checkbox" name="int" value="ux"> UX Design</label>
<label><input type="checkbox" name="int" value="data"> Data</label>

Exercise 3: a field with accessible hint and error

Write the "Password" field: visible label, required, with an associated format hint and an error message (associated and not dependent on color alone) for when the password is too short.

Solutions

Solution 1. Each control gets an associated label; the placeholder gives way to a real <label>:

<label for="name">Name</label>
<input type="text" id="name" name="name" autocomplete="name">

<label for="email">Email</label>
<input type="email" id="email" name="email" autocomplete="email">

Solution 2. The whole group goes in a <fieldset> with a <legend>:

<fieldset>
  <legend>Interests</legend>
  <label><input type="checkbox" name="int" value="dev"> Development</label>
  <label><input type="checkbox" name="int" value="ux"> UX Design</label>
  <label><input type="checkbox" name="int" value="data"> Data</label>
</fieldset>

Now the reader announces "Interests, group" before each box.

Solution 3. Visible label + required + hint with aria-describedby + associated error in text, with an icon in addition to color:

<label for="pass">Password *</label>
<input type="password" id="pass" name="pass" required
       autocomplete="new-password"
       aria-describedby="pass-hint pass-err"
       aria-invalid="true">

<p id="pass-hint">At least 8 characters, with one letter and one number.</p>
<p id="pass-err" class="error">
  <span aria-hidden="true">⚠️</span> Error: the password is too short.
</p>

Note that aria-describedby points to two ids (hint and error), so the reader reads both. When the password is valid, your code will remove aria-invalid and the error message (that's already JS logic, Module 5).

Conclusion

An accessible form isn't magic: it's labeling every control, grouping what belongs together, communicating the requirement and hints through associated text, describing errors clearly and not by color alone, and respecting the DOM's focus order. With this, Cursalia's enrollment form stops being a wall for Marta, Hugo and Lucía. We've leaned almost entirely on native HTML and only peeked at aria-describedby, aria-invalid and role="alert".

Those aria-* attributes are the door to the next lesson. In 04-03 · Using ARIA Roles and Properties we'll step back to understand what ARIA is, its five golden rules (starting with "don't use it if there's native HTML"), how the accessible name we've been using is computed (aria-label, aria-labelledby, aria-describedby) and how to describe states like aria-expanded on Cursalia's filters dropdown, always at the markup level.

© Copyright 2026. All rights reserved