Labels and associations

Bad example

Login

Remember me
<!-- ❌ Bad: Missing labels -->
<form>
  <h4>Login</h4>

  <!-- No associated label -->
  <input
    type="email"
    placeholder="Your email">

  <!-- No associated label, no autocomplete -->
  <input
    type="password"
    placeholder="Password">

  <!-- Checkbox not associated -->
  <input type="checkbox">
  <span>Remember me</span>

  <button type="submit">Log in</button>
</form>

Good example

Login

We'll never share your email
Minimum 8 characters with uppercase and numbers
<!-- ✅ Good: Explicit and associated labels -->
<form>
  <h4>Login</h4>

  <!-- Label associated via for/id -->
  <label for="email-good">Email address</label>
  <input
    type="email"
    id="email-good"
    placeholder="example@domain.com"
    required
    aria-describedby="email-help"
    autocomplete="email">
  <div id="email-help">
    We'll never share your email
  </div>

  <!-- Label with instructions -->
  <label for="password-good">Password</label>
  <input
    type="password"
    id="password-good"
    required
    aria-describedby="password-help"
    autocomplete="current-password">
  <div id="password-help">
    Minimum 8 characters with uppercase and numbers
  </div>

  <!-- Checkbox correctly associated -->
  <input type="checkbox" id="remember-good">
  <label for="remember-good">Remember me</label>

  <button type="submit">Log in</button>
</form>

Explanation: Each form field must have an explicit and properly associated label. Placeholders do not replace labels.

Validation and error messages

Bad example

Newsletter signup

<!-- ❌ Bad: Poorly announced errors -->
<form @submit.prevent="submitBadForm">
  <h4>Newsletter signup</h4>

  <!-- No label-input association -->
  <label>Name</label>
  <input
    type="text"
    v-model="badForm.name"
    :class="{ 'error': submitted && !badForm.name }">

  <!-- Visual error only -->
  <label>Email</label>
  <input
    type="email"
    v-model="badForm.email"
    :class="{ 'error': submitted && !isValidEmail(badForm.email) }">

  <!-- No error information -->
  <label>Age</label>
  <input
    type="number"
    v-model="badForm.age"
    :class="{ 'error': submitted && badForm.age < 16 }">

  <!-- Generic and useless error message -->
  <div v-if="submitted && badFormErrors.length">
    ❌ Please correct the errors
  </div>

  <button type="submit">Sign up</button>
</form>

Good example

Newsletter signup

You must be at least 16 years old
<!-- ✅ Good: Clearly associated errors -->
<form @submit.prevent="submitGoodForm" novalidate>
  <h4>Newsletter signup</h4>

  <!-- Error summary with navigation -->
  <div
    v-if="submitted && goodFormErrors.length"
    role="alert"
    aria-labelledby="error-summary-title">
    <h5 id="error-summary-title">Errors to correct:</h5>
    <ul>
      <li v-for="error in goodFormErrors" :key="error.field">
        <a :href="`#${error.field}-good-form`">
          
        </a>
      </li>
    </ul>
  </div>

  <!-- Field with specific associated error -->
  <label for="name-good-form">Full name *</label>
  <input
    type="text"
    id="name-good-form"
    v-model="goodForm.name"
    required
    aria-invalid="submitted && !goodForm.name"
    aria-describedby="name-error">
  <div
    v-if="submitted && !goodForm.name"
    id="name-error"
    role="alert">
    ❌ Name is required
  </div>

  <button type="submit">Sign up</button>
</form>

Explanation: Errors must be clearly announced, associated with the relevant fields, and provide precise instructions for correction.

Field groups with aria-labelledby

Bad example

Preferences

<!-- ❌ Bad: Disorganized structure -->
<form>
  <h4>Preferences</h4>

  <!-- Mixed elements without logic -->
  <div>
    <input type="radio" name="contact-bad"
           id="email-contact-bad" value="email">
    <label for="email-contact-bad">Email</label>

    <input type="checkbox" id="newsletter-bad"
           value="newsletter">
    <label for="newsletter-bad">Newsletter</label>
  </div>

  <div>
    <input type="radio" name="contact-bad"
           id="phone-contact-bad" value="phone">
    <label for="phone-contact-bad">Phone</label>

    <!-- Text field without context -->
    <input type="text" placeholder="Name">
  </div>

  <div>
    <input type="checkbox" id="offers-bad" value="offers">
    <label for="offers-bad">Offers</label>

    <input type="radio" name="contact-bad"
           id="sms-contact-bad" value="sms">
    <label for="sms-contact-bad">SMS</label>
  </div>

  <!-- Select without context -->
  <select>
    <option>Choose...</option>
    <option>Daily</option>
    <option>Weekly</option>
  </select>

  <button type="submit">OK</button>
</form>

Good example

Contact preferences

Personal information
Your full name as it will appear in our communications
We will use this email for all our communications
Preferred contact method *
Communication types

Select the types of communications you wish to receive

Communication frequency
Sets the maximum frequency of our mailings
<!-- ✅ Good: Logical groups with aria-labelledby -->
<form>
  <h4>Contact preferences</h4>

  <!-- Personal information section -->
  <div role="group" aria-labelledby="personal-info-title">
    <h5 id="personal-info-title">
      Personal information
    </h5>

    <label for="name-good">Full name *</label>
    <input
      type="text"
      id="name-good"
      name="name"
      required
      aria-required="true"
      aria-describedby="name-desc">
    <span id="name-desc">
      Your full name as it will appear in our communications
    </span>
  </div>

  <!-- Radio button group -->
  <div
    role="radiogroup"
    aria-labelledby="contact-method-title"
    aria-required="true">
    <h5 id="contact-method-title">
      Preferred contact method *
    </h5>

    <input
      type="radio"
      name="contact-good"
      id="email-contact-good"
      value="email"
      required>
    <label for="email-contact-good">Email</label>

    <input
      type="radio"
      name="contact-good"
      id="phone-contact-good"
      value="phone"
      required>
    <label for="phone-contact-good">Phone</label>
  </div>

  <!-- Checkbox group -->
  <div role="group" aria-labelledby="comm-types-title">
    <h5 id="comm-types-title">Communication types</h5>
    <p id="comm-types-desc">
      Select the types of communications you wish to receive
    </p>

    <input
      type="checkbox"
      id="newsletter-good"
      value="newsletter"
      aria-describedby="comm-types-desc">
    <label for="newsletter-good">Newsletter</label>
  </div>

  <button type="submit">
    Save preferences
  </button>
</form>

Explanation: Groups of related fields must be associated with their titles via aria-labelledby to create clear semantic relationships, particularly for radio buttons and checkboxes.

Complex form with real-time validation

Bad example

Create account

<!-- ❌ Bad: Intrusive validation -->
<form>
  <h4>Create account</h4>

  <!-- Immediate validation on each keystroke -->
  <label>Username</label>
  <input
    type="text"
    v-model="complexBadForm.username"
    @input="validateUsernameBad"
    :class="{ 'error': usernameBadError }">
  <!-- Error that appears/disappears constantly -->
  <div v-if="usernameBadError">
    
  </div>

  <!-- No association with aria-describedby -->
  <label>Email</label>
  <input
    type="email"
    v-model="complexBadForm.email"
    @input="validateEmailBad"
    :class="{ 'error': emailBadError }">
  <div v-if="emailBadError"></div>

  <!-- Error messages too short and not useful -->
  <!-- No autocomplete to help password managers -->
  <label>Password</label>
  <input
    type="password"
    v-model="complexBadForm.password"
    @input="validatePasswordBad"
    :class="{ 'error': passwordBadError }">
  <div v-if="passwordBadError"></div>

  <button type="submit">Create account</button>
</form>

Good example

Create account

Requirements:
  • At least 3 characters
  • Letters, numbers and underscore only
Password strength:
8+ characters
Uppercase
Number
Special character
<!-- ✅ Good: Respectful validation with feedback -->
<form>
  <h4>Create account</h4>

  <!-- Validation on blur, not on input -->
  <label for="username-complex">Username *</label>
  <input
    type="text"
    id="username-complex"
    v-model="complexGoodForm.username"
    @blur="validateUsernameGood"
    :class="{ error: usernameGoodError,
              success: usernameGoodValid }"
    aria-describedby="username-requirements username-feedback"
    aria-invalid="!!usernameGoodError"
    required>

  <!-- Clear instructions -->
  <div id="username-requirements">
    <h6>Requirements:</h6>
    <ul>
      <li :class="{ valid: complexGoodForm.username.length >= 3 }">
        At least 3 characters
      </li>
      <li :class="{ valid: /^[a-zA-Z0-9_]+$/.test(username) }">
        Letters, numbers and underscore only
      </li>
    </ul>
  </div>

  <!-- Accessible feedback -->
  <div
    v-if="usernameGoodError"
    id="username-feedback"
    role="alert"
    aria-live="polite">
    ❌ 
  </div>
  <div
    v-else-if="usernameGoodValid"
    id="username-feedback"
    role="status"
    aria-live="polite">
    ✅ Username available
  </div>

  <!-- Password strength indicator -->
  <label for="password-complex">Password *</label>
  <input
    type="password"
    id="password-complex"
    v-model="complexGoodForm.password"
    @input="validatePasswordGood"
    aria-describedby="password-strength"
    autocomplete="new-password"
    required>

  <div id="password-strength">
    <h6>Password strength:</h6>
    <div class="strength-indicator">
      <div
        class="strength-bar"
        :class="`strength-${passwordStrength}`"
        :style="{ width: `${passwordStrength * 25}%` }">
      </div>
    </div>
  </div>

  <button type="submit" :disabled="!isFormValid">
    Create account
  </button>
</form>

Explanation: For complex forms, real-time validation can help, but it must be implemented in a non-intrusive and accessible way.