UXR SEO Analyzer documentation

Introduction

Form Labels

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

  1. Screen reader users - Labels are announced when focusing fields
  2. Users with motor disabilities - Clicking label activates the field
  3. Users with cognitive disabilities - Clear labels reduce confusion
  4. 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

  1. Click on each label—does it focus the correct input?
  2. Tab through the form—does the screen reader announce each field’s purpose?
  3. 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

  1. Always use <label> elements when possible
  2. Use unique IDs for every form control
  3. Group related fields with <fieldset> and <legend>
  4. Provide clear, descriptive label text
  5. Associate error messages with their inputs
  6. Test with actual screen readers

Don’ts

  1. Don’t use placeholders as labels
  2. Don’t hide labels with display:none
  3. Don’t rely on visual proximity alone
  4. Don’t use duplicate IDs
  5. Don’t forget required field indicators
  6. Don’t skip fieldset/legend for radio/checkbox groups

References

  1. W3C - WCAG 2.2 SC 1.3.1 Info and Relationships
  2. W3C - WCAG 2.2 SC 3.3.2 Labels or Instructions
  3. WebAIM - Creating Accessible Forms
  4. MDN - The Label Element
  5. W3C - WAI Forms Tutorial
  6. W3C - ARIA in HTML

Related articles

Category hub

Hub

Accessible Web Components Hub

Building accessible web components requires understanding both the visual and programmatic aspects of user interfaces

In the same category

Introduction

Color Contrast

Color contrast is the difference in luminance between foreground (text) and background colors

Last updated: