Creating interactive web components that work for everyone isn't just good practice—it's essential. Approximately 15% of the global population experiences some form of disability, and building accessible interfaces ensures your applications are usable by all. In this guide, we'll explore how to create interactive components that are both visually appealing and fully accessible.
Why Accessibility Matters
Accessibility (often abbreviated as a11y) isn't just about compliance with standards like WCAG or legal requirements like the ADA. It's about creating interfaces that:
- Work for users with visual, motor, auditory, or cognitive disabilities
- Function across different devices and input methods
- Provide a better experience for all users, not just those with disabilities
Accessible components typically have better keyboard support, clearer focus states, and more robust interaction patterns—all of which benefit every user.
Core Accessibility Principles
Before diving into specific components, let's establish some fundamental principles:
1. Semantic HTML
Always start with the most appropriate HTML element for the job. Semantic HTML provides built-in accessibility features:
<!-- Instead of this -->
<div class="button" onclick="submitForm()">Submit</div>
<!-- Use this -->
<button type="submit">Submit</button>
2. Keyboard Accessibility
All interactive elements must be usable with a keyboard alone:
- Users should be able to navigate using Tab/Shift+Tab
- Interactive elements should respond to Enter/Space
- Custom widgets should implement expected keyboard shortcuts
3. Focus Management
Users need clear visual indicators of which element has focus:
/* Basic focus styles */
:focus {
outline: 2px solid #4299e1;
outline-offset: 2px;
}
/* For users who use a mouse/pointer */
:focus:not(:focus-visible) {
outline: none;
}
/* Only show focus indicators for keyboard users */
:focus-visible {
outline: 2px solid #4299e1;
outline-offset: 2px;
}
4. ARIA When Necessary
ARIA (Accessible Rich Internet Applications) attributes enhance HTML semantics when needed:
<button
aria-expanded="false"
aria-controls="dropdown-menu"
>
Options
</button>
<div id="dropdown-menu" hidden>
<!-- Menu items -->
</div>
Building Accessible Interactive Components
Let's explore how to build common interactive components with accessibility in mind.
Accessible Dropdown Menu
A dropdown menu needs several accessibility features:
<div class="dropdown">
<button
class="dropdown-toggle"
aria-expanded="false"
aria-controls="dropdown-content"
id="dropdown-trigger"
>
Menu
</button>
<ul
id="dropdown-content"
class="dropdown-content"
role="menu"
aria-labelledby="dropdown-trigger"
hidden
>
<li role="menuitem"><a href="#home">Home</a></li>
<li role="menuitem"><a href="#about">About</a></li>
<li role="menuitem"><a href="#contact">Contact</a></li>
</ul>
</div>
const button = document.querySelector('.dropdown-toggle');
const menu = document.querySelector('.dropdown-content');
const menuItems = menu.querySelectorAll('[role="menuitem"]');
// Toggle menu
button.addEventListener('click', () => {
const expanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', !expanded);
if (!expanded) {
menu.hidden = false;
// Focus the first menu item when opening
menuItems[0].focus();
} else {
menu.hidden = true;
}
});
// Handle keyboard navigation
menu.addEventListener('keydown', (e) => {
const currentIndex = Array.from(menuItems).indexOf(document.activeElement);
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
const nextIndex = (currentIndex + 1) % menuItems.length;
menuItems[nextIndex].focus();
break;
case 'ArrowUp':
e.preventDefault();
const prevIndex = (currentIndex - 1 + menuItems.length) % menuItems.length;
menuItems[prevIndex].focus();
break;
case 'Escape':
e.preventDefault();
button.setAttribute('aria-expanded', 'false');
menu.hidden = true;
button.focus();
break;
}
});
// Close when clicking outside
document.addEventListener('click', (e) => {
if (!menu.contains(e.target) && !button.contains(e.target)) {
button.setAttribute('aria-expanded', 'false');
menu.hidden = true;
}
});
Accessible Modal Dialog
Modals require careful focus management:
<button id="open-modal" aria-haspopup="dialog">Open Modal</button>
<div
id="modal"
class="modal"
role="dialog"
aria-labelledby="modal-title"
aria-describedby="modal-description"
aria-modal="true"
hidden
>
<div class="modal-content">
<header>
<h2 id="modal-title">Modal Title</h2>
<button id="close-modal" aria-label="Close modal">×</button>
</header>
<div id="modal-description">
<p>Modal content goes here...</p>
</div>
<footer>
<button id="cancel-modal">Cancel</button>
<button id="confirm-modal">Confirm</button>
</footer>
</div>
</div>
const openButton = document.getElementById('open-modal');
const closeButton = document.getElementById('close-modal');
const cancelButton = document.getElementById('cancel-modal');
const confirmButton = document.getElementById('confirm-modal');
const modal = document.getElementById('modal');
// Store the element that had focus before the modal was opened
let previouslyFocusedElement;
// All focusable elements in the modal
const focusableElements = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstFocusableElement = focusableElements[0];
const lastFocusableElement = focusableElements[focusableElements.length - 1];
function openModal() {
// Store the current focus
previouslyFocusedElement = document.activeElement;
// Show the modal
modal.hidden = false;
// Focus the first focusable element
firstFocusableElement.focus();
// Add event listeners
document.addEventListener('keydown', handleKeyDown);
}
function closeModal() {
// Hide the modal
modal.hidden = true;
// Restore focus to the element that had it before the modal opened
previouslyFocusedElement.focus();
// Remove event listeners
document.removeEventListener('keydown', handleKeyDown);
}
function handleKeyDown(e) {
// Close on Escape
if (e.key === 'Escape') {
closeModal();
return;
}
// Trap focus inside the modal
if (e.key === 'Tab') {
// If Shift+Tab on the first element, move to the last
if (e.shiftKey && document.activeElement === firstFocusableElement) {
e.preventDefault();
lastFocusableElement.focus();
}
// If Tab on the last element, move to the first
else if (!e.shiftKey && document.activeElement === lastFocusableElement) {
e.preventDefault();
firstFocusableElement.focus();
}
}
}
// Event listeners
openButton.addEventListener('click', openModal);
closeButton.addEventListener('click', closeModal);
cancelButton.addEventListener('click', closeModal);
confirmButton.addEventListener('click', () => {
// Handle confirmation
closeModal();
});
Accessible Tabs
Tabs need proper ARIA roles and keyboard navigation:
<div class="tabs">
<div role="tablist" aria-label="Programming Languages">
<button
id="tab-js"
role="tab"
aria-selected="true"
aria-controls="panel-js"
>
JavaScript
</button>
<button
id="tab-py"
role="tab"
aria-selected="false"
aria-controls="panel-py"
tabindex="-1"
>
Python
</button>
<button
id="tab-go"
role="tab"
aria-selected="false"
aria-controls="panel-go"
tabindex="-1"
>
Go
</button>
</div>
<div
id="panel-js"
role="tabpanel"
aria-labelledby="tab-js"
>
<p>JavaScript content here...</p>
</div>
<div
id="panel-py"
role="tabpanel"
aria-labelledby="tab-py"
hidden
>
<p>Python content here...</p>
</div>
<div
id="panel-go"
role="tabpanel"
aria-labelledby="tab-go"
hidden
>
<p>Go content here...</p>
</div>
</div>
const tabs = document.querySelectorAll('[role="tab"]');
const tabPanels = document.querySelectorAll('[role="tabpanel"]');
// Activate a tab
function activateTab(tab) {
// Deactivate all tabs
tabs.forEach(t => {
t.setAttribute('aria-selected', 'false');
t.setAttribute('tabindex', '-1');
});
// Hide all tab panels
tabPanels.forEach(p => {
p.hidden = true;
});
// Activate the selected tab
tab.setAttribute('aria-selected', 'true');
tab.setAttribute('tabindex', '0');
tab.focus();
// Show the associated panel
const panelId = tab.getAttribute('aria-controls');
const panel = document.getElementById(panelId);
panel.hidden = false;
}
// Add click event to tabs
tabs.forEach(tab => {
tab.addEventListener('click', () => {
activateTab(tab);
});
});
// Add keyboard navigation
const tabList = document.querySelector('[role="tablist"]');
tabList.addEventListener('keydown', (e) => {
// Get the index of the current tab
const currentTab = document.activeElement;
const currentIndex = Array.from(tabs).indexOf(currentTab);
// Handle arrow keys
switch (e.key) {
case 'ArrowRight':
e.preventDefault();
const nextIndex = (currentIndex + 1) % tabs.length;
activateTab(tabs[nextIndex]);
break;
case 'ArrowLeft':
e.preventDefault();
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
activateTab(tabs[prevIndex]);
break;
case 'Home':
e.preventDefault();
activateTab(tabs[0]);
break;
case 'End':
e.preventDefault();
activateTab(tabs[tabs.length - 1]);
break;
}
});
Testing Accessibility
Building accessible components is only half the battle—you also need to test them:
- Keyboard testing: Navigate your component using only the keyboard
- Screen reader testing: Use VoiceOver (macOS), NVDA or JAWS (Windows), or TalkBack (Android)
- Automated tools: Use tools like Axe, WAVE, or Lighthouse
- Contrast checking: Ensure text meets WCAG contrast requirements
- Reduced motion: Test with prefers-reduced-motion media query
Conclusion
Building accessible interactive components requires attention to detail, but the benefits extend to all users. By following these principles and patterns, you'll create interfaces that are more robust, usable, and inclusive.
Remember that accessibility is not a checkbox but a process. Start with these fundamentals, test with real users when possible, and continuously improve your components based on feedback.
By making accessibility a core part of your development process rather than an afterthought, you'll build better experiences for everyone who uses your applications.