View contents
Introduction
Form labels are text descriptions that identify the purpose of form fields. They’re essential for accessibility because screen readers rely on properly associated labels to announce what information each field expects. WCAG 2.2 includes multiple success criteria related to labels, making them a critical accessibility requirement.
Without proper labels, users who can’t see the visual layout of your form won’t know what information to enter in each field—making your forms essentially unusable for them.
What Are Form Labels?
The HTML Label Element
Labels are connected to form inputs using the <label> element:
<!-- Method 1: Using for/id association -->
<label for="email">Email Address</label>
<input type="email" id="email" name="email">
<!-- Method 2: Wrapping the input -->
<label>
Email Address
<input type="email" name="email">
</label>
Types of Form Labeling
| Method | Use Case | Example |
|---|---|---|
<label> element |
Standard form fields | Text inputs, selects, checkboxes |
aria-label |
Visually hidden labels | Search inputs, icon buttons |
aria-labelledby |
Label from other elements | Complex form groups |
title attribute |
Last resort only | Avoid when possible |
| Placeholder | Not a label | Should not replace labels |
Why Labels Matter
Who Benefits
- Screen reader users - Labels are announced when focusing fields
- Users with motor disabilities - Clicking label activates the field
- Users with cognitive disabilities - Clear labels reduce confusion
- Everyone - Larger click targets improve usability
The Click Target Benefit
When labels are properly associated, clicking the label focuses the input:
<!-- Clicking "I agree to terms" checks the checkbox -->
<input type="checkbox" id="terms">
<label for="terms">I agree to the terms and conditions</label>
This increases the clickable area significantly—especially helpful for small checkboxes and radio buttons.
WCAG Requirements
| Criterion | Level | Requirement |
|---|---|---|
| 1.3.1 Info and Relationships | A | Labels must be programmatically associated |
| 3.3.2 Labels or Instructions | A | Input fields must have labels or instructions |
| 2.4.6 Headings and Labels | AA | Labels must be descriptive |
Common Labeling Problems
1. Missing Labels
<!-- BAD: No label at all -->
<input type="text" name="username">
<!-- GOOD: Proper label association -->
<label for="username">Username</label>
<input type="text" id="username" name="username">
2. Placeholder as Label
<!-- BAD: Placeholder is not a label -->
<input type="email" placeholder="Enter your email">
<!-- GOOD: Label with optional placeholder -->
<label for="email">Email</label>
<input type="email" id="email" placeholder="[email protected]">
Why placeholders aren’t labels:
- Disappear when user starts typing
- Often have poor contrast
- Not announced by all screen readers
- Users may forget what the field is for
3. Labels Not Associated
<!-- BAD: Label exists but isn't connected -->
<label>Full Name</label>
<input type="text" id="name">
<!-- GOOD: for/id creates association -->
<label for="name">Full Name</label>
<input type="text" id="name">
4. Duplicate IDs
<!-- BAD: Both inputs have same ID -->
<label for="email">Work Email</label>
<input type="email" id="email">
<label for="email">Personal Email</label>
<input type="email" id="email">
<!-- GOOD: Unique IDs for each -->
<label for="work-email">Work Email</label>
<input type="email" id="work-email">
<label for="personal-email">Personal Email</label>
<input type="email" id="personal-email">
5. Hidden Labels Incorrectly
<!-- BAD: display:none hides from screen readers too -->
<label for="search" style="display: none;">Search</label>
<input type="search" id="search">
<!-- GOOD: Visually hidden but accessible -->
<label for="search" class="sr-only">Search</label>
<input type="search" id="search">
<style>
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
</style>
Testing Form Labels
Quick Manual Test
- Click on each label—does it focus the correct input?
- Tab through the form—does the screen reader announce each field’s purpose?
- Is every required field clearly marked?
Using Developer Tools
Chrome DevTools:
1. Inspect a form input
2. Check the Accessibility panel
3. Look for "Name" property
4. Verify it matches the visible label
Automated Tools
- axe DevTools - Detects missing and improperly associated labels
- WAVE - Highlights form fields without labels
- Lighthouse - Reports label accessibility issues
Quick Fixes
Adding Labels to Existing Forms
<!-- Before: No labels -->
<input type="text" placeholder="Name">
<input type="email" placeholder="Email">
<button>Submit</button>
<!-- After: Proper labels -->
<div>
<label for="name">Name</label>
<input type="text" id="name" placeholder="John Doe">
</div>
<div>
<label for="email">Email</label>
<input type="email" id="email" placeholder="[email protected]">
</div>
<button type="submit">Submit</button>
Labeling Icon-Only Inputs
<!-- Search with icon button -->
<label for="search" class="sr-only">Search</label>
<input type="search" id="search">
<button type="submit" aria-label="Submit search">
<svg aria-hidden="true"><!-- search icon --></svg>
</button>
Best Practices Summary
| Do | Don’t |
|---|---|
Use <label> element with for/id |
Use placeholder as label |
| Make labels descriptive and concise | Hide labels with display:none |
| Use unique IDs for each input | Duplicate IDs on a page |
| Mark required fields clearly | Rely on asterisks alone |
| Test with screen readers | Assume visual proximity is enough |
Implementation
Labeling Methods
1. The Label Element (Preferred)
The <label> element is the primary method for labeling form controls:
<!-- Method 1: Explicit association with for/id -->
<label for="username">Username</label>
<input type="text" id="username" name="username">
<!-- Method 2: Implicit association (wrapping) -->
<label>
Username
<input type="text" name="username">
</label>
When to use each method:
| Method | Pros | Cons | Best For |
|---|---|---|---|
| Explicit (for/id) | More flexible layout | Requires unique IDs | Most forms |
| Implicit (wrapping) | No ID needed | Limited styling options | Simple forms |
2. ARIA Labeling
For cases where <label> isn’t suitable:
<!-- aria-label: Provides invisible label -->
<input type="search" aria-label="Search products">
<!-- aria-labelledby: References visible text -->
<h2 id="contact-heading">Contact Information</h2>
<input type="text" aria-labelledby="contact-heading name-label">
<span id="name-label">Full Name</span>
<!-- aria-describedby: Additional description -->
<label for="password">Password</label>
<input
type="password"
id="password"
aria-describedby="password-requirements"
>
<p id="password-requirements">
Must be at least 8 characters with one number.
</p>
3. Labeling Priority
Screen readers use this priority order for accessible names:
1. aria-labelledby (highest priority)
2. aria-label
3. <label> element (for/id or wrapping)
4. title attribute (lowest priority, avoid)
Form Control Patterns
Text Inputs
<!-- Standard text input -->
<div class="form-group">
<label for="fullname">
Full Name
<span class="required" aria-hidden="true">*</span>
</label>
<input
type="text"
id="fullname"
name="fullname"
required
aria-required="true"
autocomplete="name"
>
</div>
<!-- Input with help text -->
<div class="form-group">
<label for="email">Email Address</label>
<input
type="email"
id="email"
name="email"
aria-describedby="email-help"
autocomplete="email"
>
<p id="email-help" class="help-text">
We'll never share your email with anyone.
</p>
</div>
<!-- Input with error -->
<div class="form-group has-error">
<label for="phone">Phone Number</label>
<input
type="tel"
id="phone"
name="phone"
aria-invalid="true"
aria-describedby="phone-error"
>
<p id="phone-error" class="error-text" role="alert">
Please enter a valid phone number.
</p>
</div>
Select Dropdowns
<!-- Standard select -->
<div class="form-group">
<label for="country">Country</label>
<select id="country" name="country" autocomplete="country">
<option value="">-- Select a country --</option>
<option value="us">United States</option>
<option value="ca">Canada</option>
<option value="mx">Mexico</option>
</select>
</div>
<!-- Grouped options -->
<div class="form-group">
<label for="timezone">Timezone</label>
<select id="timezone" name="timezone">
<optgroup label="Americas">
<option value="est">Eastern Time</option>
<option value="pst">Pacific Time</option>
</optgroup>
<optgroup label="Europe">
<option value="gmt">GMT</option>
<option value="cet">Central European</option>
</optgroup>
</select>
</div>
Checkboxes and Radio Buttons
<!-- Single checkbox -->
<div class="form-group">
<input type="checkbox" id="newsletter" name="newsletter">
<label for="newsletter">Subscribe to our newsletter</label>
</div>
<!-- Checkbox group -->
<fieldset>
<legend>Notification Preferences</legend>
<div class="checkbox-group">
<input type="checkbox" id="email-notify" name="notifications" value="email">
<label for="email-notify">Email notifications</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="sms-notify" name="notifications" value="sms">
<label for="sms-notify">SMS notifications</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="push-notify" name="notifications" value="push">
<label for="push-notify">Push notifications</label>
</div>
</fieldset>
<!-- Radio button group -->
<fieldset>
<legend>Shipping Method</legend>
<div class="radio-group">
<input type="radio" id="standard" name="shipping" value="standard">
<label for="standard">Standard (5-7 days) - Free</label>
</div>
<div class="radio-group">
<input type="radio" id="express" name="shipping" value="express">
<label for="express">Express (2-3 days) - $9.99</label>
</div>
<div class="radio-group">
<input type="radio" id="overnight" name="shipping" value="overnight">
<label for="overnight">Overnight - $24.99</label>
</div>
</fieldset>
Textareas
<div class="form-group">
<label for="message">Message</label>
<textarea
id="message"
name="message"
rows="5"
aria-describedby="message-info"
></textarea>
<p id="message-info" class="help-text">
Maximum 500 characters.
</p>
</div>
Complex Form Patterns
Grouped Fields with Fieldset
<!-- Address fields grouped together -->
<fieldset>
<legend>Shipping Address</legend>
<div class="form-group">
<label for="street">Street Address</label>
<input type="text" id="street" name="street" autocomplete="street-address">
</div>
<div class="form-row">
<div class="form-group">
<label for="city">City</label>
<input type="text" id="city" name="city" autocomplete="address-level2">
</div>
<div class="form-group">
<label for="state">State</label>
<select id="state" name="state" autocomplete="address-level1">
<option value="">Select...</option>
<!-- state options -->
</select>
</div>
<div class="form-group">
<label for="zip">ZIP Code</label>
<input type="text" id="zip" name="zip" autocomplete="postal-code">
</div>
</div>
</fieldset>
Date Inputs
<!-- Single date input -->
<div class="form-group">
<label for="birthdate">Date of Birth</label>
<input
type="date"
id="birthdate"
name="birthdate"
autocomplete="bday"
>
</div>
<!-- Multi-field date (legacy browsers) -->
<fieldset>
<legend>Date of Birth</legend>
<div class="date-group">
<div class="form-group">
<label for="birth-month">Month</label>
<select id="birth-month" name="birth-month" autocomplete="bday-month">
<option value="">--</option>
<option value="1">January</option>
<!-- ... -->
</select>
</div>
<div class="form-group">
<label for="birth-day">Day</label>
<input
type="number"
id="birth-day"
name="birth-day"
min="1"
max="31"
autocomplete="bday-day"
>
</div>
<div class="form-group">
<label for="birth-year">Year</label>
<input
type="number"
id="birth-year"
name="birth-year"
min="1900"
max="2024"
autocomplete="bday-year"
>
</div>
</div>
</fieldset>
Password Fields
<div class="form-group">
<label for="new-password">New Password</label>
<div class="password-wrapper">
<input
type="password"
id="new-password"
name="new-password"
aria-describedby="password-requirements"
autocomplete="new-password"
minlength="8"
>
<button
type="button"
aria-label="Show password"
aria-pressed="false"
onclick="togglePasswordVisibility(this)"
>
<svg aria-hidden="true"><!-- eye icon --></svg>
</button>
</div>
<ul id="password-requirements" class="requirements-list">
<li>At least 8 characters</li>
<li>At least one uppercase letter</li>
<li>At least one number</li>
<li>At least one special character</li>
</ul>
</div>
Framework-Specific Implementations
React
// Accessible form component
function ContactForm() {
const [formData, setFormData] = useState({});
const [errors, setErrors] = useState({});
return (
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="name">Name</label>
<input
type="text"
id="name"
name="name"
value={formData.name || ''}
onChange={handleChange}
aria-invalid={errors.name ? 'true' : 'false'}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{errors.name && (
<span id="name-error" className="error" role="alert">
{errors.name}
</span>
)}
</div>
{/* Radio group with legend */}
<fieldset>
<legend>Preferred Contact Method</legend>
{['email', 'phone', 'text'].map((method) => (
<div key={method} className="radio-option">
<input
type="radio"
id={`contact-${method}`}
name="contactMethod"
value={method}
checked={formData.contactMethod === method}
onChange={handleChange}
/>
<label htmlFor={`contact-${method}`}>
{method.charAt(0).toUpperCase() + method.slice(1)}
</label>
</div>
))}
</fieldset>
<button type="submit">Submit</button>
</form>
);
}
Vue.js
<template>
<form @submit.prevent="handleSubmit">
<div class="form-group">
<label :for="'email-' + uid">Email Address</label>
<input
:id="'email-' + uid"
v-model="form.email"
type="email"
:aria-invalid="errors.email ? 'true' : 'false'"
:aria-describedby="errors.email ? 'email-error-' + uid : undefined"
/>
<span
v-if="errors.email"
:id="'email-error-' + uid"
class="error"
role="alert"
>
{{ errors.email }}
</span>
</div>
<fieldset>
<legend>Subscription Type</legend>
<div
v-for="option in subscriptionOptions"
:key="option.value"
class="radio-option"
>
<input
:id="'sub-' + option.value + '-' + uid"
v-model="form.subscription"
type="radio"
name="subscription"
:value="option.value"
/>
<label :for="'sub-' + option.value + '-' + uid">
{{ option.label }}
</label>
</div>
</fieldset>
<button type="submit">Subscribe</button>
</form>
</template>
<script setup>
import { ref, computed } from 'vue'
const uid = computed(() => Math.random().toString(36).substr(2, 9))
const form = ref({ email: '', subscription: '' })
const errors = ref({})
</script>
Testing Form Labels
Automated Testing
// Cypress with axe-core
describe('Form Accessibility', () => {
beforeEach(() => {
cy.visit('/contact');
cy.injectAxe();
});
it('should have no label violations', () => {
cy.checkA11y(null, {
rules: {
'label': { enabled: true },
'label-title-only': { enabled: true },
'form-field-multiple-labels': { enabled: true }
}
});
});
it('should associate labels with inputs', () => {
cy.get('label[for="email"]').should('exist');
cy.get('#email').should('exist');
cy.get('label[for="email"]').click();
cy.get('#email').should('be.focused');
});
});
Screen Reader Testing Commands
Testing label associations:
VoiceOver (macOS):
- Tab to input → Should announce label text
- VO + F → Read form controls
- VO + Command + J → Jump to next form control
NVDA (Windows):
- F → Jump to next form field
- NVDA + F7 → Elements list (select Form fields)
- Tab → Navigate and hear labels announced
JAWS (Windows):
- F → Jump to next form field
- Insert + F5 → Form controls list
- Tab → Navigate with label announcement
Manual Testing Checklist
## Form Label Audit
### For Each Input:
- [ ] Has associated label (check in DevTools accessibility panel)
- [ ] Label is descriptive and clear
- [ ] Clicking label focuses the input
- [ ] Required fields are indicated
- [ ] Error messages are associated with inputs
### For Field Groups:
- [ ] Related fields grouped with <fieldset>
- [ ] Groups have <legend> element
- [ ] Radio/checkbox groups are properly labeled
### For Hidden Labels:
- [ ] Uses sr-only class (not display:none)
- [ ] Still announced by screen readers
- [ ] Context is provided via aria-label or aria-labelledby
Error Handling and Validation
Accessible Error Messages
<div class="form-group" :class="{ 'has-error': hasError }">
<label for="email">Email Address</label>
<input
type="email"
id="email"
name="email"
:aria-invalid="hasError"
:aria-describedby="hasError ? 'email-error' : null"
>
<p
v-if="hasError"
id="email-error"
class="error-message"
role="alert"
>
Please enter a valid email address.
</p>
</div>
Live Validation Announcements
// Announce validation errors to screen readers
function announceError(fieldId, message) {
const liveRegion = document.getElementById('live-announcements');
liveRegion.textContent = `Error: ${message}`;
// Clear after announcement
setTimeout(() => {
liveRegion.textContent = '';
}, 1000);
}
<div
id="live-announcements"
aria-live="polite"
aria-atomic="true"
class="sr-only"
></div>
Best Practices Summary
Do’s
- Always use
<label>elements when possible - Use unique IDs for every form control
- Group related fields with
<fieldset>and<legend> - Provide clear, descriptive label text
- Associate error messages with their inputs
- Test with actual screen readers
Don’ts
- Don’t use placeholders as labels
- Don’t hide labels with
display:none - Don’t rely on visual proximity alone
- Don’t use duplicate IDs
- Don’t forget required field indicators
- Don’t skip fieldset/legend for radio/checkbox groups
Related Articles
- Accessible Components Hub - Full forms guide
- ARIA Labels Guide - Advanced labeling
References
- W3C - WCAG 2.2 SC 1.3.1 Info and Relationships
- W3C - WCAG 2.2 SC 3.3.2 Labels or Instructions
- WebAIM - Creating Accessible Forms
- MDN - The Label Element
- W3C - WAI Forms Tutorial
- W3C - ARIA in HTML