Building Accessible Forms That Actually Work
Build forms that work for everyone: correct ARIA labels, error handling, keyboard navigation and screen reader support — beyond checkbox compliance.

Forms are where users hand you their information, and they're where accessibility failures hurt the most. A screen reader user who can't figure out what a field expects, a keyboard user who can't reach the submit button, a user with low vision who can't see the error message—these aren't edge cases. They're people trying to give you money, sign up for your service, or submit critical information.
Most "accessible" forms pass automated checks but fail real users. The checkbox compliance approach—"every input has a label"—misses the experience. Truly accessible forms communicate clearly, handle errors gracefully, and work with any input method.
Labels That Communicate Intent
The first rule of accessible forms: every input must have a programmatically associated label. But association alone isn't enough—the label must communicate what the field expects.
<!-- ❌ Label exists but doesn't help -->
<label for="field1">Field 1</label>
<input id="field1" type="text" />
<!-- ❌ Placeholder is not a label — disappears on input -->
<input type="email" placeholder="Enter your email" />
<!-- ✅ Clear label with format hint -->
<div class="field-group">
<label for="phone">Phone number</label>
<input
id="phone"
type="tel"
aria-describedby="phone-hint"
autocomplete="tel"
/>
<span id="phone-hint" class="hint">
Format: (555) 123-4567
</span>
</div>
<!-- ✅ Required fields clearly indicated -->
<div class="field-group">
<label for="email">
Email address
<span aria-hidden="true" class="required">*</span>
</label>
<input
id="email"
type="email"
required
aria-required="true"
autocomplete="email"
aria-describedby="email-hint"
/>
<span id="email-hint" class="hint">
We'll send your confirmation here
</span>
</div>The aria-describedby attribute links supplementary text to the input. Screen readers announce it after the label, giving users the full context: "Email address, required. Edit text. We'll send your confirmation here."
Error Handling That Guides
Error messages need to reach three audiences simultaneously: sighted users who scan visually, screen reader users who navigate programmatically, and keyboard users who tab through the form.
<!-- ✅ Accessible error handling pattern -->
<form novalidate aria-label="Registration form">
<!-- Error summary at top of form -->
<div
id="error-summary"
role="alert"
aria-live="assertive"
class="error-summary"
hidden
>
<h2>There are 2 problems with your submission</h2>
<ul>
<li><a href="#email">Enter a valid email address</a></li>
<li><a href="#password">Password must be at least 8 characters</a></li>
</ul>
</div>
<div class="field-group">
<label for="email">Email address</label>
<input
id="email"
type="email"
required
aria-required="true"
aria-invalid="true"
aria-describedby="email-error"
autocomplete="email"
/>
<span id="email-error" class="error" role="alert">
Enter a valid email address
</span>
</div>
<div class="field-group">
<label for="password">Password</label>
<input
id="password"
type="password"
required
aria-required="true"
aria-invalid="true"
aria-describedby="password-error password-requirements"
autocomplete="new-password"
/>
<span id="password-error" class="error" role="alert">
Password must be at least 8 characters
</span>
<span id="password-requirements" class="hint">
At least 8 characters with one uppercase letter and one number
</span>
</div>
</form>// Form validation with accessible error handling
class AccessibleForm {
private form: HTMLFormElement;
private errorSummary: HTMLElement;
constructor(form: HTMLFormElement) {
this.form = form;
this.errorSummary = form.querySelector('#error-summary')!;
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
}
private handleSubmit(e: Event): void {
e.preventDefault();
const errors = this.validate();
if (errors.length > 0) {
this.showErrors(errors);
} else {
this.clearErrors();
this.submitForm();
}
}
private showErrors(
errors: Array<{ fieldId: string; message: string }>
): void {
// Show error summary
this.errorSummary.hidden = false;
const list = this.errorSummary.querySelector('ul')!;
list.innerHTML = '';
for (const error of errors) {
const li = document.createElement('li');
const link = document.createElement('a');
link.href = `#${error.fieldId}`;
link.textContent = error.message;
li.appendChild(link);
list.appendChild(li);
// Mark individual fields
const field = document.getElementById(error.fieldId);
if (field) {
field.setAttribute('aria-invalid', 'true');
const errorEl = document.getElementById(`${error.fieldId}-error`);
if (errorEl) {
errorEl.textContent = error.message;
errorEl.hidden = false;
}
}
}
// Move focus to error summary so screen readers announce it
this.errorSummary.focus();
}
private clearErrors(): void {
this.errorSummary.hidden = true;
this.form.querySelectorAll('[aria-invalid]').forEach((el) => {
el.removeAttribute('aria-invalid');
});
}
}Keyboard Navigation That Flows
Every interaction in your form must work without a mouse. This means logical tab order, visible focus indicators, and keyboard-accessible custom controls.
/* ✅ Visible focus indicators — never remove outline without replacement */
/* ❌ Don't do this: *:focus { outline: none; } */
input:focus,
select:focus,
textarea:focus,
button:focus {
outline: 3px solid #2563eb;
outline-offset: 2px;
}
/* For browsers that support it, only show focus ring on keyboard nav */
input:focus:not(:focus-visible) {
outline: none;
}
input:focus-visible {
outline: 3px solid #2563eb;
outline-offset: 2px;
}
/* Error state styling */
input[aria-invalid="true"] {
border-color: #dc2626;
border-width: 2px;
}
input[aria-invalid="true"]:focus {
outline-color: #dc2626;
}
/* Skip link — first focusable element on the page */
.skip-link {
position: absolute;
top: -100%;
left: 0;
padding: 0.5rem 1rem;
background: #1e293b;
color: white;
z-index: 100;
}
.skip-link:focus {
top: 0;
}// ✅ Custom dropdown that works with keyboard
class AccessibleSelect {
private button: HTMLButtonElement;
private listbox: HTMLUListElement;
private options: HTMLLIElement[];
private activeIndex: number = -1;
handleKeyDown(event: KeyboardEvent): void {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.activeIndex = Math.min(
this.activeIndex + 1,
this.options.length - 1
);
this.updateActiveDescendant();
break;
case 'ArrowUp':
event.preventDefault();
this.activeIndex = Math.max(this.activeIndex - 1, 0);
this.updateActiveDescendant();
break;
case 'Enter':
case ' ':
event.preventDefault();
if (this.activeIndex >= 0) {
this.selectOption(this.activeIndex);
}
break;
case 'Escape':
this.closeDropdown();
this.button.focus();
break;
case 'Home':
event.preventDefault();
this.activeIndex = 0;
this.updateActiveDescendant();
break;
case 'End':
event.preventDefault();
this.activeIndex = this.options.length - 1;
this.updateActiveDescendant();
break;
}
}
private updateActiveDescendant(): void {
const activeOption = this.options[this.activeIndex];
this.listbox.setAttribute(
'aria-activedescendant',
activeOption.id
);
activeOption.scrollIntoView({ block: 'nearest' });
}
}Multi-Step Forms and Progress
Complex forms split across multiple steps need clear progress indicators and the ability to navigate between steps.
<!-- ✅ Accessible multi-step form -->
<div role="group" aria-label="Checkout form">
<!-- Step indicator -->
<nav aria-label="Checkout progress">
<ol class="steps">
<li aria-current="step">
<span class="step-number">1</span>
<span>Shipping</span>
</li>
<li>
<span class="step-number">2</span>
<span>Payment</span>
</li>
<li>
<span class="step-number">3</span>
<span>Review</span>
</li>
</ol>
</nav>
<!-- Current step -->
<section aria-label="Step 1: Shipping information">
<h2>Shipping information</h2>
<!-- Live region announces step changes -->
<div
aria-live="polite"
aria-atomic="true"
class="sr-only"
>
Step 1 of 3: Shipping information
</div>
<!-- Form fields for this step -->
<div class="field-group">
<label for="address">Street address</label>
<input
id="address"
type="text"
required
autocomplete="street-address"
/>
</div>
<div class="button-group">
<button type="button" disabled>Previous</button>
<button type="button" onclick="nextStep()">
Continue to payment
</button>
</div>
</section>
</div>Testing Accessibility for Real
Automated tools catch about 30% of accessibility issues. The rest requires manual testing with actual assistive technology.
## Accessibility testing checklist for forms
### Automated (run first)
- [ ] axe-core or Lighthouse audit: zero violations
- [ ] HTML validator: no markup errors
- [ ] All inputs have associated labels
- [ ] All images have alt text
### Keyboard testing (5 minutes)
- [ ] Tab through entire form — logical order?
- [ ] Every interactive element reachable via Tab
- [ ] Focus indicator visible on every element
- [ ] Custom controls work with Enter/Space
- [ ] Dropdown menus work with arrow keys
- [ ] Escape closes dropdowns/modals
- [ ] No keyboard traps (can always Tab out)
### Screen reader testing (15 minutes)
- [ ] NVDA or VoiceOver: navigate form by tab
- [ ] Each field announces: label, type, required state
- [ ] Error messages announced when they appear
- [ ] Error summary focused and read on submit
- [ ] Format hints read after label
- [ ] Multi-step progress communicated
### Visual testing
- [ ] Errors visible without relying on color alone
- [ ] Text meets 4.5:1 contrast ratio
- [ ] Form usable at 200% zoom
- [ ] Form works in high contrast modeKey Takeaways
Every form input must have a programmatically associated label using for/id attributes—placeholders are not labels because they disappear when users start typing, and screen readers may not consistently announce them. Error handling needs three layers: an error summary at the top of the form that receives focus on submission, individual error messages linked to fields via aria-describedby, and aria-invalid attributes on problematic fields so assistive technology communicates the state. Never remove focus outlines without providing a visible replacement—use :focus-visible to show focus rings only during keyboard navigation, keeping the form clean for mouse users while remaining navigable for keyboard users. Test with real assistive technology, not just automated scanners—tab through every form with your keyboard, navigate it with a screen reader, and zoom to 200%, because automated tools like Lighthouse catch roughly 30% of accessibility issues while manual testing reveals the experience gaps that matter most.


