// 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.
// 01 · live demo
Live Demo
Try submitting the form empty to see accessible error messages. Use Tab to navigate between fields.
// 02 · the code
The Code
<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>
/* 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;
}
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');
}
});
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 |
// 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
autocompleteattributes 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-describedbyhint 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
<label> element. Placeholders are for example input values only — never as a substitute for labels.
<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.
aria-describedby. Without this association, screen reader users have to search the page to find why their submission failed.
: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 |