Accessible Form Design: Beyond the Basics
Build truly accessible forms — error handling patterns, keyboard navigation, screen reader testing, multi-step flows, and the ARIA attributes that actually matter.
Strategic Systems Architect & Enterprise Software Developer
Most developers know the basics of form accessibility — use label elements, add alt text to images, do not rely on color alone. But functional accessibility goes far beyond checking boxes on a checklist. It means building forms that people with motor impairments, visual impairments, and cognitive disabilities can actually complete without frustration. The gap between "technically accessible" and "usably accessible" is where most forms fail.
I have audited dozens of forms with screen readers and keyboard-only navigation. The problems are remarkably consistent, and the solutions are not complicated — they are just not the patterns most tutorials teach.
Labels, Descriptions, and Error Associations
Every input needs a programmatic label. The <label> element with a for attribute is the most solid method. Placeholder text is not a label — it disappears when the user starts typing, which means screen reader users lose context once they begin entering data.
<div>
<label for="email">Email address</label>
<input
id="email"
type="email"
aria-describedby="email-help email-error"
aria-invalid="true"
/>
<p id="email-help" class="text-sm text-neutral-500">
We will never share your email
</p>
<p id="email-error" role="alert" class="text-sm text-error-500">
Please enter a valid email address
</p>
</div>
Three critical attributes here. aria-describedby links the input to both the help text and the error message — screen readers announce these when the input receives focus. aria-invalid="true" tells assistive technology that the current value is wrong. role="alert" on the error message forces screen readers to announce it immediately when it appears, not just when the user navigates to it.
The aria-describedby attribute accepts multiple IDs separated by spaces. This is how you associate help text, validation requirements, and error messages with a single input without cluttering the label. Screen readers announce them in order after the label text.
For complex inputs like date pickers or address groups, use fieldset and legend instead of individual labels. The legend provides context for the entire group, and individual inputs within the fieldset still have their own labels:
<fieldset>
<legend>Shipping address</legend>
<label for="street">Street</label>
<input id="street" type="text" />
<label for="city">City</label>
<input id="city" type="text" />
</fieldset>
This structure tells screen reader users "you are filling out a shipping address" before they encounter each field, which is context sighted users get from the visual layout.
Error Handling That Works for Everyone
Error presentation determines whether a user can recover from a mistake or abandons the form entirely. The pattern that works across all abilities:
Summarize errors at the top of the form with links to each invalid field. When the user submits with errors, move focus to the error summary. This gives screen reader users an overview of what needs fixing and lets keyboard users jump directly to each problem field.
<div v-if="errors.length" ref="errorSummary" tabindex="-1" role="alert">
<h2>Please fix the following errors:</h2>
<ul>
<li v-for="error in errors" :key="error.field">
<a :href="`#${error.field}`">{{ error.message }}</a>
</li>
</ul>
</div>
The tabindex="-1" allows programmatic focus without adding the element to the tab order. After submission fails, call errorSummary.value?.focus() to move the user's attention to the error list.
Show errors inline at each field simultaneously. The summary provides navigation, and inline errors provide context when the user reaches each field. Both are necessary — neither alone is sufficient.
The timing of inline error display matters for usability. Showing errors before the user has attempted the field is hostile. The form validation patterns article covers the technical implementation of validate-on-blur-then-on-change, which is the standard that balances feedback timeliness with user patience.
Keyboard Navigation Patterns
Every form interaction must work with keyboard alone. This means every custom widget — dropdowns, date pickers, toggle switches, sliders — needs keyboard event handlers that match the expected behavior from the WAI-ARIA Authoring Practices.
For custom select dropdowns, the expected keyboard behavior is: Space or Enter to open, Arrow keys to navigate options, Enter to select, Escape to close. Home and End jump to the first and last option. Type-ahead allows jumping to options by typing the first letter.
<script setup lang="ts">
function handleKeydown(event: KeyboardEvent) {
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
focusNextOption()
break
case 'ArrowUp':
event.preventDefault()
focusPreviousOption()
break
case 'Enter':
case ' ':
event.preventDefault()
selectFocusedOption()
break
case 'Escape':
closeDropdown()
// Return focus to trigger button
triggerRef.value?.focus()
break
}
}
</script>
The event.preventDefault() calls are essential — without them, arrow keys scroll the page and space triggers a page-down. Focus management on close is equally important. When a dropdown closes, focus must return to the element that opened it. Losing focus to document.body disorients keyboard users.
Tab order should follow the visual order of form elements. If your layout uses CSS Grid or Flexbox to reorder elements visually, the tab order will not match what the user sees. Use tabindex sparingly and only to correct mismatches — never to create a custom tab order across the entire form.
Multi-Step Form Accessibility
Multi-step forms add navigation complexity. Users need to understand where they are in the process, what they have completed, and what remains. A progress indicator with proper ARIA attributes communicates this:
<nav aria-label="Form progress">
<ol>
<li aria-current="step">
<span>Step 2 of 4: Contact Details</span>
</li>
</ol>
</nav>
The aria-current="step" attribute tells screen readers which step is active. When the user moves between steps, announce the transition: "Step 3 of 4: Payment Information" via a live region.
Preserve completed form data when navigating between steps. If a user goes back to step one to correct their name, step two's data must persist. Losing filled-in data across step navigation is a usability failure that disproportionately affects users who navigate more slowly, including many users with disabilities.
Allow step navigation in both directions. Preventing backward navigation is a dark pattern that traps users who made an error in a previous step. The only exception is payment processing, where backward navigation after submission initiation creates transaction risks — and even then, provide a clear explanation.
Testing with real assistive technology is non-negotiable. VoiceOver on macOS, NVDA on Windows, and TalkBack on Android each interpret ARIA attributes differently. Automated accessibility testing catches structural issues but cannot evaluate whether the experience actually makes sense when spoken aloud. Spend thirty minutes filling out your form with a screen reader before shipping it. The issues you find will surprise you.