Building Accessible Web Applications: A Complete Guide
June 10, 2024 (1y ago)
Accessibility isn't just a nice-to-have—it's essential. Building accessible web applications ensures everyone can use your product, regardless of their abilities. Here's how to do it right.
Understanding Accessibility
The Four Principles
- Perceivable: Information must be presentable in ways users can perceive
- Operable: Interface components must be operable
- Understandable: Information and operation must be understandable
- Robust: Content must be robust enough for various assistive technologies
Legal Requirements
- WCAG 2.1 AA: Standard for most public websites
- Section 508: US federal government requirements
- ADA: Americans with Disabilities Act
- EN 301 549: European accessibility requirements
Semantic HTML: The Foundation
Use Proper HTML Elements
<!-- Bad: Using divs for everything -->
<div class="header">Navigation</div>
<div class="main">
<div class="article">Article content</div>
</div>
<div class="footer">Footer</div>
<!-- Good: Semantic HTML -->
<header>Navigation</header>
<main>
<article>Article content</article>
</main>
<footer>Footer</footer>
Headings Structure
<!-- Proper heading hierarchy -->
<h1>Main Page Title</h1>
<section>
<h2>Section Title</h2>
<h3>Subsection Title</h3>
<p>Content...</p>
</section>
<section>
<h2>Another Section</h2>
<h3>Another Subsection</h3>
<p>More content...</p>
</section>
<!-- Never skip heading levels -->
<h1>Title</h1>
<h3>Bad: Skipped h2</h3>
Lists and Navigation
<!-- Navigation lists -->
<nav aria-label="Main navigation">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</nav>
<!-- Breadcrumb navigation -->
<nav aria-label="Breadcrumb">
<ol>
<li><a href="/">Home</a></li>
<li><a href="/products">Products</a></li>
<li><span aria-current="page">Current Product</span></li>
</ol>
</nav>
ARIA Attributes
Landmark Roles
<!-- Define page regions -->
<header role="banner">
<h1>Site Title</h1>
</header>
<nav role="navigation" aria-label="Main">
<!-- Navigation content -->
</nav>
<main role="main">
<!-- Main content -->
</main>
<aside role="complementary" aria-label="Sidebar">
<!-- Sidebar content -->
</aside>
<footer role="contentinfo">
<!-- Footer content -->
</footer>
Dynamic Content Updates
<!-- Live regions for dynamic content -->
<div aria-live="polite" id="status-message">
<!-- Status updates appear here -->
</div>
<div aria-live="assertive" id="error-message">
<!-- Important error messages -->
</div>
<!-- JavaScript to update live regions -->
function showMessage(message, type = 'polite') {
const region = document.getElementById(type === 'error' ? 'error-message' : 'status-message');
region.textContent = message;
// Clear after 5 seconds
setTimeout(() => {
region.textContent = '';
}, 5000);
}
Form Accessibility
<form>
<fieldset>
<legend>Contact Information</legend>
<div class="form-group">
<label for="name">
Full Name
<span aria-label="required" aria-hidden="true">*</span>
</label>
<input
type="text"
id="name"
name="name"
required
aria-describedby="name-help"
aria-invalid="false"
>
<div id="name-help" class="help-text">
Enter your full name as it appears on official documents
</div>
</div>
<div class="form-group">
<label for="email">Email Address</label>
<input
type="email"
id="email"
name="email"
required
aria-describedby="email-error"
aria-invalid="true"
>
<div id="email-error" class="error-message" role="alert">
Please enter a valid email address
</div>
</div>
<div class="form-group">
<fieldset>
<legend>Preferred Contact Method</legend>
<div>
<input type="radio" id="contact-email" name="contact" value="email">
<label for="contact-email">Email</label>
</div>
<div>
<input type="radio" id="contact-phone" name="contact" value="phone">
<label for="contact-phone">Phone</label>
</div>
</fieldset>
</div>
<button type="submit">Submit</button>
</fieldset>
</form>
Keyboard Navigation
Focus Management
// Focus trap for modals
class FocusTrap {
constructor(container) {
this.container = container;
this.focusableElements = this.getFocusableElements();
this.firstElement = this.focusableElements[0];
this.lastElement = this.focusableElements[this.focusableElements.length - 1];
}
getFocusableElements() {
return this.container.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
}
handleKeyDown(e) {
if (e.key === 'Tab') {
if (e.shiftKey) {
if (document.activeElement === this.firstElement) {
this.lastElement.focus();
e.preventDefault();
}
} else {
if (document.activeElement === this.lastElement) {
this.firstElement.focus();
e.preventDefault();
}
}
}
if (e.key === 'Escape') {
this.close();
}
}
activate() {
this.firstElement.focus();
this.container.addEventListener('keydown', this.handleKeyDown.bind(this));
}
deactivate() {
this.container.removeEventListener('keydown', this.handleKeyDown.bind(this));
}
}
// Usage with modal
const modal = document.getElementById('modal');
const focusTrap = new FocusTrap(modal);
modal.classList.add('active');
focusTrap.activate();
Skip Links
<!-- Skip to main content link (visible when focused) -->
<a href="#main-content" class="skip-link">
Skip to main content
</a>
<!-- Target for skip link -->
<main id="main-content">
<!-- Main content -->
</main>
<style>
.skip-link {
position: absolute;
top: -40px;
left: 6px;
background: #000;
color: white;
padding: 8px;
text-decoration: none;
z-index: 1000;
}
.skip-link:focus {
top: 6px;
}
</style>
Screen Reader Considerations
Alternative Text for Images
<!-- Meaningful images -->
<img src="chart.png" alt="Sales increased by 25% from Q1 to Q2">
<!-- Decorative images -->
<img src="decoration.png" alt="" role="presentation">
<!-- Complex images with detailed descriptions -->
<img src="complex-chart.png" alt="Sales chart showing quarterly data">
<div class="sr-only">
<p>Detailed description of the chart:</p>
<ul>
<li>Q1: $100,000 in sales</li>
<li>Q2: $125,000 in sales (25% increase)</li>
<li>Q3: $118,000 in sales (6% decrease)</li>
<li>Q4: $140,000 in sales (19% increase)</li>
</ul>
</div>
Data Tables
<table>
<caption>Monthly Sales Report</caption>
<thead>
<tr>
<th scope="col">Month</th>
<th scope="col">Sales</th>
<th scope="col">Target</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">January</th>
<td>$50,000</td>
<td>$45,000</td>
<td>✓ Above target</td>
</tr>
<tr>
<th scope="row">February</th>
<td>$42,000</td>
<td>$45,000</td>
<td>✗ Below target</td>
</tr>
</tbody>
</table>
Color and Visual Design
Sufficient Contrast
/* Ensure sufficient color contrast */
.text-primary {
color: #333333; /* Good contrast on white background */
}
.text-secondary {
color: #666666; /* Still readable */
}
.error-message {
color: #d32f2f; /* Red with good contrast */
font-weight: bold; /* Don't rely on color alone */
}
.success-message {
color: #388e3c; /* Green with good contrast */
font-weight: bold;
}
/* Focus indicators */
button:focus,
input:focus,
a:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
Don't Rely on Color Alone
<!-- Bad: Only color indicates status -->
<span style="color: red">Error</span>
<span style="color: green">Success</span>
<!-- Good: Color + text/icon -->
<span class="error" aria-label="Error">
<span aria-hidden="true">✗</span>
Error
</span>
<span class="success" aria-label="Success">
<span aria-hidden="true">✓</span>
Success
</span>
Accessible JavaScript
Component Accessibility
// Accessible accordion component
class AccessibleAccordion {
constructor(element) {
this.element = element;
this.headers = element.querySelectorAll('[data-accordion-header]');
this.panels = element.querySelectorAll('[data-accordion-panel]');
this.init();
}
init() {
this.headers.forEach((header, index) => {
const panel = this.panels[index];
// Set up ARIA attributes
header.setAttribute('aria-expanded', 'false');
header.setAttribute('aria-controls', panel.id);
panel.setAttribute('aria-labelledby', header.id);
panel.setAttribute('hidden', '');
// Add event listeners
header.addEventListener('click', () => this.toggle(index));
header.addEventListener('keydown', (e) => this.handleKeydown(e, index));
});
}
toggle(index) {
const header = this.headers[index];
const panel = this.panels[index];
const isExpanded = header.getAttribute('aria-expanded') === 'true';
// Update ARIA attributes
header.setAttribute('aria-expanded', !isExpanded);
if (isExpanded) {
panel.setAttribute('hidden', '');
} else {
panel.removeAttribute('hidden');
}
}
handleKeydown(e, index) {
const { key } = e;
switch (key) {
case 'Enter':
case ' ':
e.preventDefault();
this.toggle(index);
break;
case 'ArrowDown':
e.preventDefault();
this.focusNext(index);
break;
case 'ArrowUp':
e.preventDefault();
this.focusPrevious(index);
break;
case 'Home':
e.preventDefault();
this.headers[0].focus();
break;
case 'End':
e.preventDefault();
this.headers[this.headers.length - 1].focus();
break;
}
}
focusNext(currentIndex) {
const nextIndex = (currentIndex + 1) % this.headers.length;
this.headers[nextIndex].focus();
}
focusPrevious(currentIndex) {
const prevIndex = currentIndex === 0 ? this.headers.length - 1 : currentIndex - 1;
this.headers[prevIndex].focus();
}
}
Accessible Modals
<!-- Modal structure -->
<div
id="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
class="modal"
hidden
>
<div class="modal-content">
<h2 id="modal-title">Confirm Action</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 id="cancel-btn" onclick="closeModal()">Cancel</button>
<button id="confirm-btn" onclick="confirmAction()">Delete</button>
</div>
<button
class="modal-close"
aria-label="Close modal"
onclick="closeModal()"
>
×
</button>
</div>
</div>
<!-- JavaScript for modal management -->
function openModal() {
const modal = document.getElementById('modal');
modal.removeAttribute('hidden');
// Store previous focus
const previousFocus = document.activeElement;
modal.dataset.previousFocus = previousFocus.id;
// Set up focus trap
const focusTrap = new FocusTrap(modal.querySelector('.modal-content'));
focusTrap.activate();
// Prevent body scroll
document.body.style.overflow = 'hidden';
}
function closeModal() {
const modal = document.getElementById('modal');
modal.setAttribute('hidden', '');
// Restore focus
const previousFocusId = modal.dataset.previousFocus;
if (previousFocusId) {
document.getElementById(previousFocusId).focus();
}
// Restore body scroll
document.body.style.overflow = '';
}
Testing for Accessibility
Automated Testing
// Accessibility testing with axe-core
import axe from 'axe-core';
async function testAccessibility() {
const results = await axe.run(document.body, {
rules: {
'color-contrast': { enabled: true },
'keyboard-navigation': { enabled: true },
'aria-labels': { enabled: true }
}
});
if (results.violations.length > 0) {
console.error('Accessibility violations found:', results.violations);
results.violations.forEach(violation => {
console.error(`- ${violation.description}`);
violation.nodes.forEach(node => {
console.error(` Element: ${node.target.join(', ')}`);
});
});
} else {
console.log('No accessibility violations found!');
}
return results;
}
// Integration with Jest
test('component is accessible', async () => {
render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Manual Testing Checklist
## Accessibility Testing Checklist
### Keyboard Navigation
- [ ] Can I navigate to all interactive elements with Tab?
- [ ] Is there a visible focus indicator?
- [ ] Can I operate all controls with keyboard?
- [ ] Can I escape from modal dialogs with Escape?
### Screen Reader Testing
- [ ] Are all images described?
- [ ] Are form fields properly labeled?
- [ ] Are headings structured logically?
- [ ] Are dynamic changes announced?
### Visual Testing
- [ ] Is text readable at 200% zoom?
- [ ] Is there sufficient color contrast?
- [ ] Can I understand content without color?
- [ ] Are focus indicators visible?
### Cognitive Testing
- [ ] Is language clear and simple?
- [ ] Are error messages helpful?
- [ ] Is navigation predictable?
- [ ] Are timeouts reasonable?
Performance and Accessibility
Optimizing for Screen Readers
// Lazy loading with accessibility in mind
class AccessibleLazyLoad {
constructor(element, loadFunction) {
this.element = element;
this.loadFunction = loadFunction;
this.observer = null;
this.init();
}
init() {
// Check if user prefers reduced motion
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) {
// Load immediately for users who prefer reduced motion
this.loadContent();
} else {
// Use intersection observer for others
this.setupIntersectionObserver();
}
}
setupIntersectionObserver() {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.loadContent();
this.observer.unobserve(entry.target);
}
});
},
{ rootMargin: '100px' }
);
this.observer.observe(this.element);
}
async loadContent() {
const content = await this.loadFunction();
this.element.innerHTML = content;
// Announce to screen readers
const announcement = document.createElement('div');
announcement.setAttribute('aria-live', 'polite');
announcement.setAttribute('aria-atomic', 'true');
announcement.className = 'sr-only';
announcement.textContent = 'New content loaded';
document.body.appendChild(announcement);
setTimeout(() => {
document.body.removeChild(announcement);
}, 1000);
}
}
Best Practices Summary
Development Guidelines
- Start with semantic HTML - Use elements for their intended purpose
- Add ARIA thoughtfully - Don't overcompensate with ARIA
- Test with keyboard - Ensure everything works without a mouse
- Provide alternatives - Don't rely on single sensory experiences
- Test with real users - Include people with disabilities in testing
Common Mistakes to Avoid
<!-- Don't do this -->
<div onclick="handleClick()">Click me</div>
<!-- Do this instead -->
<button onclick="handleClick()">Click me</button>
<!-- Don't do this -->
<img src="chart.png">
<!-- Do this instead -->
<img src="chart.png" alt="Sales chart showing 25% growth">
<!-- Don't do this -->
<div class="error" style="color: red">Error message</div>
<!-- Do this instead -->
<div class="error" role="alert" aria-live="polite">
<span aria-hidden="true">⚠️</span>
Error message
</div>
Tools and Resources
Testing Tools
- axe DevTools: Browser extension for accessibility testing
- WAVE: Web accessibility evaluation tool
- Lighthouse: Built-in accessibility audit
- Screen readers: NVDA (Windows), VoiceOver (Mac), TalkBack (Android)
Development Resources
- WebAIM: Comprehensive accessibility resources
- A11y Project: Community-driven accessibility resource
- MDN Accessibility: Web accessibility documentation
- W3C WCAG: Official accessibility guidelines
Conclusion
Accessibility is not a checklist—it's a mindset. Building accessible applications requires empathy, understanding, and continuous learning.
Remember that accessibility benefits everyone:
- Keyboard navigation helps power users
- Clear text helps non-native speakers
- Good contrast helps users in bright environments
- Captions help users in noisy environments
Start small, test often, and always consider your users. An accessible web is a better web for everyone.