View contents
Accessible Web Components Hub: Forms, Navigation, and Interactive Elements
Introduction
Building accessible web components requires understanding both the visual and programmatic aspects of user interfaces. While native HTML elements come with built-in accessibility, custom interactive components need additional consideration to work properly with assistive technologies.
This hub page provides comprehensive guidance on creating accessible forms, navigation menus, dialogs, tabs, and other interactive elements that work for all users, regardless of how they interact with your website.
Why Component Accessibility Matters
The Challenge
Custom Component Problems:
┌─────────────────────────────────────────────────────────┐
│ 97% of home pages have detectable WCAG failures │
│ Form accessibility is #3 most common failure category │
│ Custom controls often lack keyboard support │
│ ARIA misuse is worse than no ARIA at all │
└─────────────────────────────────────────────────────────┘
Key Principles
| Principle | Description |
|---|---|
| Perceivable | Users can perceive component state and content |
| Operable | Works with keyboard, mouse, touch, and assistive tech |
| Understandable | Behavior is predictable and labels are clear |
| Robust | Works across browsers and assistive technologies |
Accessible Forms
Form Labels
Every form input must have an associated label:
<!-- Method 1: Explicit label association -->
<label for="username">Username</label>
<input type="text" id="username" name="username">
<!-- Method 2: Implicit label (wrapping) -->
<label>
Email Address
<input type="email" name="email">
</label>
<!-- Method 3: aria-labelledby (for complex cases) -->
<span id="label-name">Full Name</span>
<span id="hint-name">As shown on your ID</span>
<input type="text" aria-labelledby="label-name hint-name">
Required Fields
<!-- Indicate required fields properly -->
<label for="email">
Email Address
<span aria-hidden="true">*</span>
</label>
<input type="email" id="email" name="email"
required aria-required="true">
<!-- Legend explaining asterisk -->
<p aria-hidden="true">* Required fields</p>
Error Handling
<!-- Accessible error messages -->
<label for="password">Password</label>
<input type="password" id="password" name="password"
aria-describedby="password-error"
aria-invalid="true">
<span id="password-error" role="alert">
Password must be at least 8 characters
</span>
Form Fieldsets
<!-- Group related fields -->
<fieldset>
<legend>Shipping Address</legend>
<label for="street">Street Address</label>
<input type="text" id="street" name="street">
<label for="city">City</label>
<input type="text" id="city" name="city">
</fieldset>
Complete Accessible Form Example
<form aria-labelledby="form-title" novalidate>
<h2 id="form-title">Contact Us</h2>
<div class="form-group">
<label for="name">
Full Name
<span class="required" aria-hidden="true">*</span>
</label>
<input type="text" id="name" name="name"
required aria-required="true"
autocomplete="name">
</div>
<div class="form-group">
<label for="email">
Email
<span class="required" aria-hidden="true">*</span>
</label>
<input type="email" id="email" name="email"
required aria-required="true"
autocomplete="email">
</div>
<div class="form-group">
<label for="message">Message</label>
<textarea id="message" name="message" rows="5"></textarea>
</div>
<button type="submit">Send Message</button>
</form>
Accessible Navigation
Main Navigation
<nav aria-label="Main">
<ul role="list">
<li><a href="/" aria-current="page">Home</a></li>
<li><a href="/products">Products</a></li>
<li><a href="/about">About</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</nav>
Dropdown Menus
<nav aria-label="Main">
<ul role="list">
<li>
<button aria-expanded="false" aria-haspopup="true"
aria-controls="products-menu">
Products
<span aria-hidden="true">▼</span>
</button>
<ul id="products-menu" role="menu" hidden>
<li role="none">
<a role="menuitem" href="/software">Software</a>
</li>
<li role="none">
<a role="menuitem" href="/hardware">Hardware</a>
</li>
</ul>
</li>
</ul>
</nav>
Keyboard Navigation Pattern
// Dropdown keyboard support
const dropdown = document.querySelector('[aria-haspopup]');
const menu = document.querySelector('[role="menu"]');
const items = menu.querySelectorAll('[role="menuitem"]');
dropdown.addEventListener('keydown', (e) => {
switch(e.key) {
case 'ArrowDown':
case 'Enter':
case ' ':
e.preventDefault();
menu.hidden = false;
dropdown.setAttribute('aria-expanded', 'true');
items[0].focus();
break;
}
});
menu.addEventListener('keydown', (e) => {
const current = document.activeElement;
const index = Array.from(items).indexOf(current);
switch(e.key) {
case 'ArrowDown':
e.preventDefault();
items[(index + 1) % items.length].focus();
break;
case 'ArrowUp':
e.preventDefault();
items[(index - 1 + items.length) % items.length].focus();
break;
case 'Escape':
menu.hidden = true;
dropdown.setAttribute('aria-expanded', 'false');
dropdown.focus();
break;
}
});
Breadcrumbs
<nav aria-label="Breadcrumb">
<ol>
<li><a href="/">Home</a></li>
<li><a href="/products">Products</a></li>
<li><a href="/products/software" aria-current="page">Software</a></li>
</ol>
</nav>
Skip Links
<!-- Place at the very beginning of body -->
<a href="#main-content" class="skip-link">
Skip to main content
</a>
<!-- CSS for skip link -->
<style>
.skip-link {
position: absolute;
left: -10000px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
}
.skip-link:focus {
position: fixed;
top: 10px;
left: 10px;
width: auto;
height: auto;
padding: 1rem;
background: #000;
color: #fff;
z-index: 10000;
}
</style>
Accessible Dialogs/Modals
Modal Structure
<div id="modal" role="dialog" aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
hidden>
<div class="modal-content">
<h2 id="modal-title">Confirm Delete</h2>
<p id="modal-description">
Are you sure you want to delete this item?
This action cannot be undone.
</p>
<div class="modal-actions">
<button type="button" class="btn-secondary"
data-dismiss="modal">Cancel</button>
<button type="button" class="btn-danger">Delete</button>
</div>
<button type="button" class="modal-close"
aria-label="Close dialog">×</button>
</div>
</div>
Focus Management
class AccessibleModal {
constructor(modalElement) {
this.modal = modalElement;
this.focusableElements = this.modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
this.firstFocusable = this.focusableElements[0];
this.lastFocusable = this.focusableElements[this.focusableElements.length - 1];
this.previouslyFocused = null;
}
open() {
this.previouslyFocused = document.activeElement;
this.modal.hidden = false;
this.firstFocusable.focus();
document.body.classList.add('modal-open');
this.trapFocus();
}
close() {
this.modal.hidden = true;
document.body.classList.remove('modal-open');
this.previouslyFocused?.focus();
}
trapFocus() {
this.modal.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === this.firstFocusable) {
e.preventDefault();
this.lastFocusable.focus();
} else if (!e.shiftKey && document.activeElement === this.lastFocusable) {
e.preventDefault();
this.firstFocusable.focus();
}
}
if (e.key === 'Escape') {
this.close();
}
});
}
}
Accessible Tabs
Tab Panel Pattern
<div class="tabs">
<div role="tablist" aria-label="Product Information">
<button role="tab" id="tab-1" aria-selected="true"
aria-controls="panel-1" tabindex="0">
Description
</button>
<button role="tab" id="tab-2" aria-selected="false"
aria-controls="panel-2" tabindex="-1">
Specifications
</button>
<button role="tab" id="tab-3" aria-selected="false"
aria-controls="panel-3" tabindex="-1">
Reviews
</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
<h3>Product Description</h3>
<p>...</p>
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
<h3>Technical Specifications</h3>
<p>...</p>
</div>
<div role="tabpanel" id="panel-3" aria-labelledby="tab-3" hidden>
<h3>Customer Reviews</h3>
<p>...</p>
</div>
</div>
Tab Keyboard Navigation
// Arrow key navigation between tabs
const tablist = document.querySelector('[role="tablist"]');
const tabs = tablist.querySelectorAll('[role="tab"]');
tablist.addEventListener('keydown', (e) => {
const currentTab = document.activeElement;
const index = Array.from(tabs).indexOf(currentTab);
let newIndex;
switch(e.key) {
case 'ArrowRight':
newIndex = (index + 1) % tabs.length;
break;
case 'ArrowLeft':
newIndex = (index - 1 + tabs.length) % tabs.length;
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = tabs.length - 1;
break;
default:
return;
}
e.preventDefault();
activateTab(tabs[newIndex]);
});
function activateTab(tab) {
// Deactivate all tabs
tabs.forEach(t => {
t.setAttribute('aria-selected', 'false');
t.setAttribute('tabindex', '-1');
document.getElementById(t.getAttribute('aria-controls')).hidden = true;
});
// Activate selected tab
tab.setAttribute('aria-selected', 'true');
tab.setAttribute('tabindex', '0');
tab.focus();
document.getElementById(tab.getAttribute('aria-controls')).hidden = false;
}
Accessible Accordions
<div class="accordion">
<h3>
<button aria-expanded="false" aria-controls="section1-content"
id="section1-header" class="accordion-trigger">
Section 1
<span aria-hidden="true">+</span>
</button>
</h3>
<div id="section1-content" role="region"
aria-labelledby="section1-header" hidden>
<p>Content for section 1...</p>
</div>
<h3>
<button aria-expanded="false" aria-controls="section2-content"
id="section2-header" class="accordion-trigger">
Section 2
<span aria-hidden="true">+</span>
</button>
</h3>
<div id="section2-content" role="region"
aria-labelledby="section2-header" hidden>
<p>Content for section 2...</p>
</div>
</div>
Accessible Buttons
Button vs Link
<!-- Use button for actions -->
<button type="button" onclick="submitForm()">Submit</button>
<button type="button" onclick="deleteItem()">Delete</button>
<!-- Use links for navigation -->
<a href="/products">View Products</a>
<a href="/about">About Us</a>
<!-- Icon buttons need accessible names -->
<button type="button" aria-label="Close">
<svg aria-hidden="true">...</svg>
</button>
<button type="button" aria-label="Search">
<span class="icon-search" aria-hidden="true"></span>
</button>
Toggle Buttons
<!-- Toggle button pattern -->
<button type="button" aria-pressed="false"
onclick="toggleDarkMode(this)">
<span class="icon" aria-hidden="true">🌙</span>
Dark Mode
</button>
<script>
function toggleDarkMode(button) {
const pressed = button.getAttribute('aria-pressed') === 'true';
button.setAttribute('aria-pressed', !pressed);
document.body.classList.toggle('dark-mode');
}
</script>
Focus Management
Visible Focus Indicators
/* Good focus indicators */
:focus {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
/* High contrast focus for dark backgrounds */
.dark-theme :focus {
outline-color: #fff;
box-shadow: 0 0 0 2px #005fcc;
}
/* Custom focus for buttons */
button:focus {
outline: 3px solid #005fcc;
outline-offset: 2px;
}
/* Never just remove focus */
/* BAD: :focus { outline: none; } */
Focus Order
<!-- Logical reading order -->
<header>...</header>
<nav>...</nav>
<main>
<h1>...</h1>
<!-- Content in logical order -->
</main>
<aside>...</aside>
<footer>...</footer>
<!-- Use tabindex sparingly -->
<!-- tabindex="0" - adds to tab order -->
<!-- tabindex="-1" - programmatically focusable only -->
<!-- Avoid tabindex > 0 -->
Testing Components
Automated Testing
// Jest + axe-core example
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
describe('Accessible Form', () => {
it('should have no accessibility violations', async () => {
const { container } = render(<ContactForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
Manual Testing Checklist
- [ ] All interactive elements are keyboard accessible
- [ ] Focus order is logical
- [ ] Focus indicators are visible
- [ ] Screen reader announces correctly
- [ ] ARIA attributes are valid
- [ ] Color is not the only indicator
Related Articles
Form Accessibility
Focus & Navigation
Related Hubs
References
- W3C - WAI-ARIA Authoring Practices
- W3C - Forms Tutorial
- MDN - ARIA Roles
- WebAIM - Accessible Forms
- A11Y Project - Resources
- Deque - Code Library