// pattern

Accessible Forms Pattern

Forms with proper label association, grouped inputs, inline validation, and accessible error messages. Native HTML handles most of the work — ARIA fills the gaps for error states.

native-html wcag-2.2-aa

// 01 · live demo

Live Demo

Try submitting the form empty to see accessible error messages. Use Tab to navigate between fields.

We'll use this to send your confirmation.
Priority
Notifications
Form submitted successfully. (This is a demo — no data was sent.)

// 02 · the code

The Code

HTML
<form novalidate>
  <!-- Text input with label and error -->
  <div class="form-group">
    <label for="full-name">
      Full name <span class="required" aria-hidden="true">*</span>
    </label>
    <input type="text" id="full-name" name="full-name"
           required aria-required="true"
           autocomplete="name">
    <div class="form-error" id="full-name-error" role="alert">
      Please enter your full name.
    </div>
  </div>

  <!-- Email with hint text -->
  <div class="form-group">
    <label for="email">
      Email <span class="required" aria-hidden="true">*</span>
    </label>
    <span class="form-hint" id="email-hint">
      We'll use this to send your confirmation.
    </span>
    <input type="email" id="email" name="email"
           required aria-required="true"
           aria-describedby="email-hint"
           autocomplete="email">
    <div class="form-error" id="email-error" role="alert">
      Please enter a valid email address.
    </div>
  </div>

  <!-- Select dropdown -->
  <div class="form-group">
    <label for="topic">Topic</label>
    <select id="topic" name="topic" required>
      <option value="">Select a topic...</option>
      <option value="general">General inquiry</option>
      <option value="bug">Bug report</option>
    </select>
  </div>

  <!-- Radio group in fieldset -->
  <fieldset>
    <legend>Priority</legend>
    <div class="radio-option">
      <input type="radio" id="low" name="priority" value="low">
      <label for="low">Low</label>
    </div>
    <div class="radio-option">
      <input type="radio" id="medium" name="priority" value="medium">
      <label for="medium">Medium</label>
    </div>
  </fieldset>

  <!-- Checkbox group in fieldset -->
  <fieldset>
    <legend>Notifications</legend>
    <div class="checkbox-option">
      <input type="checkbox" id="notify-email" name="notify">
      <label for="notify-email">Email notifications</label>
    </div>
  </fieldset>

  <button type="submit">Submit</button>
</form>
CSS
/* Labels */
label {
  display: block;
  font-weight: 600;
  margin-bottom: 0.25rem;
}

.required {
  color: #b91c1c;
}

/* Inputs */
input[type="text"],
input[type="email"],
select,
textarea {
  display: block;
  width: 100%;
  padding: 0.6rem 0.75rem;
  font-family: inherit;
  font-size: 1rem;
  border: 1px solid #d1d5db;
  border-radius: 0.5rem;
  /* SC 2.5.8: minimum 24x24 target */
  min-height: 2.75rem;
}

/* Focus: visible ring, not just color */
input:focus,
select:focus,
textarea:focus {
  border-color: var(--color-primary);
  box-shadow: 0 0 0 3px rgba(26, 86, 219, 0.15);
  outline: none;
}

/* Error state: border + aria-invalid */
input[aria-invalid="true"],
select[aria-invalid="true"] {
  border-color: #b91c1c;
}

input[aria-invalid="true"]:focus {
  box-shadow: 0 0 0 3px rgba(185, 28, 28, 0.15);
}

/* Error message */
.form-error {
  color: #b91c1c;
  font-size: 0.875rem;
  margin-top: 0.25rem;
}

/* Fieldset for grouping */
fieldset {
  border: 1px solid #d1d5db;
  border-radius: 0.5rem;
  padding: 1rem 1.5rem;
}

legend {
  font-weight: 600;
  padding: 0 0.25rem;
}

/* Radio/checkbox sizing: meets SC 2.5.8 */
input[type="radio"],
input[type="checkbox"] {
  width: 1.25rem;
  height: 1.25rem;
}
JavaScript (validation)
const form = document.getElementById('my-form');

form.addEventListener('submit', (e) => {
  e.preventDefault();
  let firstError = null;

  // Validate each required field
  form.querySelectorAll('[required]').forEach(input => {
    const errorEl = document.getElementById(
      `${input.id}-error`
    );

    const isEmpty = input.type === 'radio'
      ? !form.querySelector(`[name="${input.name}"]:checked`)
      : !input.value.trim();

    if (isEmpty) {
      // Mark invalid and show error
      input.setAttribute('aria-invalid', 'true');
      input.setAttribute('aria-describedby',
        [input.getAttribute('aria-describedby'), `${input.id}-error`]
          .filter(Boolean).join(' ')
      );
      errorEl.classList.add('is-visible');
      if (!firstError) firstError = input;
    } else {
      // Clear error state
      input.removeAttribute('aria-invalid');
      errorEl.classList.remove('is-visible');
    }
  });

  if (firstError) {
    // Focus the first invalid field
    firstError.focus();
  } else {
    // Form is valid — submit
    console.log('Form submitted');
  }
});
Why novalidate on the form? The novalidate attribute disables the browser's built-in validation UI so we can provide our own accessible error messages. Browser validation popups are inconsistent across browsers, poorly styled, and difficult for screen reader users to interact with. Custom validation gives us full control over the error experience.

// 03 · why these decisions

Why These Decisions

Why for/id label association?

The for attribute on a <label> creates a programmatic link to the input's id. When a screen reader user focuses the input, it announces the label text. Without this association, screen readers announce an unlabeled input — the user has no idea what to type. As a bonus, clicking the label focuses the input, which increases the effective target size.

Why aria-describedby for error messages?

When a field has an error, the user needs to hear the error message when they focus the field — not just see it visually. aria-describedby links additional descriptive text to an input. Screen readers announce it after the label and role, giving the user immediate context: "Email, edit, required, Please enter a valid email address."

Why aria-invalid="true"?

The aria-invalid attribute tells screen readers that a field's current value is incorrect. Without it, the user hears "Email, edit, required" — with it, they hear "Email, edit, required, invalid." This immediate signal helps users identify which fields need attention without having to find and read error messages separately.

Why both required and aria-required="true"?

The native required attribute enables browser validation (which we disable with novalidate to use custom errors) and adds the "required" state to the accessibility tree in most browsers. aria-required="true" provides a redundant but explicit signal for screen readers. Some older assistive technology only recognizes one or the other, so using both ensures broad coverage.

Why <fieldset>/<legend> for radio buttons?

Without a <fieldset>, each radio button's label only describes its own option: "Low," "Medium," "High." The user doesn't know what question they're answering. The <legend> provides the group label: "Priority." Screen readers announce "Priority, group" when entering the fieldset, then each option within context. This is critical for radio buttons and checkbox groups where the individual labels only make sense in context.

Why role="alert" on error messages?

When an error message appears dynamically (after validation), screen readers may not notice it unless it's a live region. role="alert" makes the element an assertive live region — when its content becomes visible, screen readers interrupt to announce it immediately. This ensures the user knows validation failed without needing to navigate to find the error.

// 04 · keyboard interaction

Keyboard Interaction

Key Action
Tab Moves focus to the next form control (input, select, textarea, button)
Shift + Tab Moves focus to the previous form control
Space Toggles checkbox state; activates buttons; opens select dropdowns in some browsers
Enter Submits the form when focused on an input or button
Arrow Up / Arrow Down Moves between radio button options within a group; navigates select dropdown options
Focus on first error When validation fails, focus moves to the first invalid field. This is critical for keyboard users — without it, they'd have to tab through the entire form to find which field has an error.

// 05 · wcag 2.2 success criteria

WCAG 2.2 Success Criteria

This pattern satisfies the following WCAG 2.2 success criteria:

  • 1.3.1 Info and Relationships Level A — Labels are programmatically associated with inputs via for/id. Related inputs are grouped with <fieldset>/<legend>. Structure is conveyed through semantics, not just visual layout.
  • 1.4.1 Use of Color Level A — Error states use red border color plus a text error message and an icon — never color alone.
  • 2.1.1 Keyboard Level A — All form controls are natively keyboard accessible. Tab navigation, arrow keys for radios, Space for checkboxes, Enter to submit.
  • 3.3.2 Labels or Instructions Level A — Every input has a visible label. Required fields are indicated with an asterisk. Hint text provides additional context where needed.
  • 3.3.1 Error Identification Level A — Errors are identified in text, not just color. Each error message clearly describes what went wrong and which field it relates to.
  • 3.3.3 Error Suggestion Level AA — Error messages suggest how to fix the problem (e.g., "Please enter a valid email address" tells the user the format is wrong).
  • 4.1.2 Name, Role, Value Level A — All controls have accessible names (via labels), proper roles (native elements), and state information (aria-invalid, aria-required).
  • 2.5.8 Target Size (Minimum) Level AA — All inputs, checkboxes, radio buttons, and buttons meet the 24x24px minimum target size.
  • 3.3.7 Redundant Entry Level A — The form uses autocomplete attributes on name and email fields so browsers can auto-fill previously entered data.

// 06 · screen reader behavior

Screen Reader Behavior

When navigating to a text input

  • NVDA: "Full name, star, edit, required, blank" — announces label, required state, role, and current value
  • JAWS: "Full name, star, required, edit, type in text" — similar with an action prompt
  • VoiceOver: "Full name, required, text field" — announces label, required state, and role

When navigating to a field with a hint

  • NVDA: "Email, star, edit, required, We'll use this to send your confirmation" — announces the aria-describedby hint after the label
  • JAWS: "Email, star, required, edit, We'll use this to send your confirmation" — hint announced as description
  • VoiceOver: "Email, required, text field, We'll use this to send your confirmation" — hint included in announcement

When a field has an error

  • NVDA: "Full name, star, edit, required, invalid, Please enter your full name" — announces the invalid state and the error message via aria-describedby
  • JAWS: "Full name, star, required, invalid, edit, Please enter your full name" — error message included
  • VoiceOver: "Full name, required, invalid data, text field, Please enter your full name" — clear invalid state announcement

The role="alert" on error messages also causes screen readers to announce the error immediately when it appears, even if the user hasn't moved focus to the field.

When entering a fieldset (radio group)

  • NVDA: "Priority, grouping" followed by "Low, radio button, not checked, 1 of 3" — group context then individual option
  • JAWS: "Priority group, Low, radio button not checked" — merges group name with first option
  • VoiceOver: "Priority, group" then "Low, radio button, 1 of 3" — clear group identification

// 07 · common mistakes

Common Mistakes

Using placeholder text as the label Placeholder text disappears when the user starts typing, leaving them with no context about what the field is for. It typically has poor contrast (gray on white), and screen readers may not announce it consistently. Always use a visible <label> element. Placeholders are for example input values only — never as a substitute for labels.
Missing <fieldset> for radio button groups Without a fieldset, each radio button's label is announced in isolation: "Low, radio button." The user doesn't know "Low" what. The <fieldset>/<legend> combination provides the group label: "Priority, group, Low, radio button" — now the option makes sense in context.
Error messages not associated with inputs Showing a red error message near a field doesn't help screen reader users unless the message is programmatically connected to the input via aria-describedby. Without this association, screen reader users have to search the page to find why their submission failed.
Color-only error indication Turning a field border red doesn't communicate the error to users who can't see color differences. Always combine the visual indicator (red border) with a text error message and, where possible, an icon. This meets SC 1.4.1 Use of Color.
Not focusing the first error on submit When validation fails, the user needs to know what went wrong and where. If you don't move focus to the first invalid field, keyboard users are left at the submit button with no idea which field has an error or how far back they need to tab.
Relying only on browser-native validation Browser validation popups (:invalid pseudo-class, constraint validation API) are inconsistently styled, hard to customize, and may not integrate well with screen readers. Disable native validation with novalidate and implement custom validation that you fully control for consistent error messaging.

// 08 · native html vs. aria

Native HTML vs. ARIA

Forms are a case where native HTML does most of the work, but ARIA fills important gaps for validation states and descriptions.

Feature Native HTML ARIA supplement
Label association <label for="id"> — fully native Not needed (aria-label as fallback only)
Required state required attribute aria-required="true" for broader AT support
Input grouping <fieldset>/<legend> role="group" + aria-labelledby as fallback
Invalid state CSS :invalid pseudo-class (visual only) aria-invalid="true" — needed for screen readers
Error message association No native equivalent aria-describedby pointing to error element — required
Hint/help text No native equivalent aria-describedby pointing to hint element — required
Live error announcements No native equivalent role="alert" on error container — required
Autocomplete autocomplete attribute — fully native Not needed
Keyboard interaction Fully native for all form controls Not needed
The verdict Forms are where "native HTML first, ARIA when needed" is most clearly demonstrated. Labels, grouping, required state, keyboard interaction, and autocomplete are all native. But error messages, hint text association, invalid states, and live announcements require ARIA. Both work together — neither is sufficient alone.