Form labels and structure
Every input needs a name. Every group needs a heading. Get this part right and the rest follows.
A form control without a label is a mystery box. Sighted users guess from the placeholder or the input next to it. A screen reader user, though, hears something like "edit, blank" — and that's it. No clue what to type. No clue why this field exists. So before we touch errors, dialogs, or any of the dynamic stuff, we have to nail the boring foundation: every control needs a name, and every related set of controls needs a group label.
This is the layer everything else sits on. If a field has no accessible name, all the aria-invalid and aria-describedby in the world won't save it.
What a label actually does
A label is a short, visible piece of text that names a form control. The <label> element is the HTML way to attach that text to an <input>, <select>, or <textarea> so the browser knows they belong together.
Two things happen when the association works:
- Click target grows. Tapping the label text focuses the input — huge for small checkboxes and on touch screens.
- Accessible name is set. Screen readers announce "Email, edit" instead of "edit, blank".
That accessible name — the name announced by assistive tech — is what aria-invalid, aria-describedby, and validation messages all latch onto later. No name, nothing to attach to.
Quick sanity check: tap the label text. If the input doesn't get focus, the label isn't actually associated with it.
Two ways to associate a label
There are exactly two patterns. Pick one and stay consistent.
1. for / id (explicit). The label sits anywhere; its for attribute matches the input's id.
<label for="email">Email</label> <input id="email" name="email" type="email" />
2. Wrapping (implicit). The label contains the input. No id needed.
<label> Email <input name="email" type="email" /> </label>
Both are valid. Wrapping is fewer attributes and immune to typos in id. Explicit lets the label live anywhere in the layout — useful when CSS Grid or floats decouple a label from its field.
Here's the wrong way that looks fine until a screen reader reads it:
<!-- DON'T: a div pretending to be a label --> <div class="label">Email</div> <input name="email" type="email" />
Visually identical. Programmatically, the input has no name. A screen reader will read the surrounding text and guess — sometimes well, sometimes not.
aria-label and aria-labelledby exist as last resorts. They don't render visible text and they don't grow the click target. Reach for a real <label> first.
Grouping radios and checkboxes
A single radio button labeled "Yes" is meaningless. Yes to what? Each radio in a group needs its own label (Yes/No), but the group itself needs a heading too.
The HTML answer is <fieldset> plus <legend>. Fieldset wraps the related controls; legend is the question they all answer.
<fieldset> <legend>Send marketing emails?</legend> <label> <input type="radio" name="marketing" value="yes" /> Yes </label> <label> <input type="radio" name="marketing" value="no" /> No </label> </fieldset>
A screen reader announces this as "Send marketing emails? Yes, radio button, 1 of 2." The legend is glued to every option in the group — that's the magic.
Same pattern for checkbox groups, date pickers split into day/month/year, address blocks — anywhere multiple inputs answer one combined question.
If <legend>'s default styling is too rigid for your design, you can visually hide it (clip-path / position: absolute off-screen) and replace it with a styled heading only if the visible heading is also exposed via aria-labelledby on the fieldset. Don't quietly drop the group name.
An alternative pattern: role="group" with aria-labelledby pointing at a heading. It works, but fieldset/legend is native, semantic, and keyboard-accessible by default.
Placeholders are not labels
The grey hint text inside an empty input — that's a placeholder. It is not a label. Three problems:
- It disappears the moment the user types, so they can't re-read it.
- Its low contrast often fails WCAG color requirements.
- Some screen readers don't announce it at all; others announce it instead of the label.
If you want a hint, put a real label above and a hint below. The hint can live in a <small> or <p> and get wired to the input with aria-describedby (we'll use that exact pattern for errors in Lesson 3).
<label for="username">Username</label> <input id="username" name="username" aria-describedby="username-hint" /> <small id="username-hint">3–20 letters, no spaces.</small>