
Accessibility in React is the practice of making your web applications usable by people with disabilities. Building accessible React applications ensures that your software can be used by everyone, including users who rely on assistive technologies like screen readers, keyboard navigation, or voice control software. This chapter covers comprehensive accessibility practices specific to React development.
Understanding Web Accessibility
Web accessibility means that websites, tools, and technologies are designed and developed so that people with disabilities can use them effectively. This includes users with:
- Visual impairments (blindness, low vision, color blindness)
- Auditory impairments (deafness, hearing loss)
- Motor impairments (limited fine motor control, paralysis)
- Cognitive impairments (learning disabilities, memory issues)
WCAG Guidelines
The Web Content Accessibility Guidelines (WCAG) 2.1 provide the foundation for web accessibility. They are organized around four principles:
- Perceivable – Information must be presentable in ways users can perceive
- Operable – Interface components must be operable by all users
- Understandable – Information and UI operation must be understandable
- Robust – Content must be robust enough for various assistive technologies
Semantic HTML in React
Using semantic HTML is the foundation of accessibility. React’s JSX makes it easy to use proper semantic elements:
import React from 'react';
// Bad: Using divs for everything
const BadExample = () => {
return (
<div>
<div onClick={handleClick}>Click me</div>
<div>
<div>Article Title</div>
<div>Article content goes here...</div>
</div>
</div>
);
};
// Good: Using semantic HTML
const GoodExample = () => {
return (
<main>
<button onClick={handleClick}>Click me</button>
<article>
<h1>Article Title</h1>
<p>Article content goes here...</p>
</article>
</main>
);
};
// Navigation example
const Navigation = () => {
return (
<nav aria-label="Main navigation">
<ul>
<li><a href="/home">Home</a></li>
<li><a href="/about">About</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</nav>
);
};
// Form example with proper labels
const AccessibleForm = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
return (
<form onSubmit={handleSubmit}>
<fieldset>
<legend>Login Information</legend>
<div>
<label htmlFor="email">Email Address</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
aria-describedby="email-help"
/>
<div id="email-help">We'll never share your email</div>
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
aria-describedby="password-requirements"
/>
<div id="password-requirements">
Password must be at least 8 characters long
</div>
</div>
<button type="submit">Login</button>
</fieldset>
</form>
);
};
ARIA Attributes in React
ARIA (Accessible Rich Internet Applications) attributes provide additional semantic information to assistive technologies:
import React, { useState, useRef } from 'react';
// Dropdown with ARIA attributes
const AccessibleDropdown = ({ options, onSelect, label }) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const buttonRef = useRef();
const listRef = useRef();
const handleKeyDown = (event) => {
switch (event.key) {
case 'Enter':
case ' ':
event.preventDefault();
setIsOpen(!isOpen);
break;
case 'ArrowDown':
event.preventDefault();
if (isOpen) {
setSelectedIndex(prev =>
prev < options.length - 1 ? prev + 1 : prev
);
} else {
setIsOpen(true);
}
break;
case 'ArrowUp':
event.preventDefault();
if (isOpen) {
setSelectedIndex(prev => prev > 0 ? prev - 1 : prev);
}
break;
case 'Escape':
setIsOpen(false);
buttonRef.current?.focus();
break;
}
};
return (
<div className="dropdown">
<button
ref={buttonRef}
type="button"
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-labelledby="dropdown-label"
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
>
{label}
<span aria-hidden="true">â–¼</span>
</button>
{isOpen && (
<ul
ref={listRef}
role="listbox"
aria-labelledby="dropdown-label"
onKeyDown={handleKeyDown}
>
{options.map((option, index) => (
<li
key={option.id}
role="option"
aria-selected={index === selectedIndex}
onClick={() => {
onSelect(option);
setIsOpen(false);
}}
className={index === selectedIndex ? 'selected' : ''}
>
{option.label}
</li>
))}
</ul>
)}
</div>
);
};
// Modal with proper ARIA
const AccessibleModal = ({ isOpen, onClose, title, children }) => {
const modalRef = useRef();
const previousFocus = useRef();
useEffect(() => {
if (isOpen) {
previousFocus.current = document.activeElement;
modalRef.current?.focus();
} else {
previousFocus.current?.focus();
}
}, [isOpen]);
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
onClose();
}
};
if (!isOpen) return null;
return (
<div
className="modal-overlay"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div
ref={modalRef}
className="modal-content"
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<div className="modal-header">
<h2 id="modal-title">{title}</h2>
<button
onClick={onClose}
aria-label="Close modal"
className="close-button"
>
×
</button>
</div>
<div className="modal-body">
{children}
</div>
</div>
</div>
);
};
// Tab component with ARIA
const AccessibleTabs = ({ tabs }) => {
const [activeTab, setActiveTab] = useState(0);
const handleKeyDown = (event, index) => {
switch (event.key) {
case 'ArrowRight':
event.preventDefault();
setActiveTab((index + 1) % tabs.length);
break;
case 'ArrowLeft':
event.preventDefault();
setActiveTab(index === 0 ? tabs.length - 1 : index - 1);
break;
case 'Home':
event.preventDefault();
setActiveTab(0);
break;
case 'End':
event.preventDefault();
setActiveTab(tabs.length - 1);
break;
}
};
return (
<div className="tabs">
<div role="tablist" aria-label="Content tabs">
{tabs.map((tab, index) => (
<button
key={tab.id}
role="tab"
aria-selected={activeTab === index}
aria-controls={`panel-${tab.id}`}
id={`tab-${tab.id}`}
tabIndex={activeTab === index ? 0 : -1}
onClick={() => setActiveTab(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, index) => (
<div
key={tab.id}
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
hidden={activeTab !== index}
>
{tab.content}
</div>
))}
</div>
);
};
Keyboard Navigation
Ensure your React components are fully navigable via keyboard:
import React, { useState, useRef, useEffect } from 'react';
// Custom hook for focus management
const useFocusManagement = (items) => {
const [focusedIndex, setFocusedIndex] = useState(0);
const itemRefs = useRef([]);
const moveFocus = (direction) => {
const newIndex = direction === 'next'
? (focusedIndex + 1) % items.length
: focusedIndex === 0 ? items.length - 1 : focusedIndex - 1;
setFocusedIndex(newIndex);
itemRefs.current[newIndex]?.focus();
};
const handleKeyDown = (event) => {
switch (event.key) {
case 'ArrowDown':
case 'ArrowRight':
event.preventDefault();
moveFocus('next');
break;
case 'ArrowUp':
case 'ArrowLeft':
event.preventDefault();
moveFocus('previous');
break;
case 'Home':
event.preventDefault();
setFocusedIndex(0);
itemRefs.current[0]?.focus();
break;
case 'End':
event.preventDefault();
const lastIndex = items.length - 1;
setFocusedIndex(lastIndex);
itemRefs.current[lastIndex]?.focus();
break;
}
};
return {
focusedIndex,
setFocusedIndex,
itemRefs,
handleKeyDown
};
};
// Accessible menu component
const AccessibleMenu = ({ items, onItemClick }) => {
const { focusedIndex, itemRefs, handleKeyDown } = useFocusManagement(items);
return (
<ul
role="menu"
onKeyDown={handleKeyDown}
className="accessible-menu"
>
{items.map((item, index) => (
<li key={item.id} role="none">
<button
ref={(ref) => itemRefs.current[index] = ref}
role="menuitem"
tabIndex={focusedIndex === index ? 0 : -1}
onClick={() => onItemClick(item)}
onFocus={() => setFocusedIndex(index)}
>
{item.label}
</button>
</li>
))}
</ul>
);
};
// Skip navigation link
const SkipNavigation = () => {
return (
<a
href="#main-content"
className="skip-nav"
onFocus={(e) => e.target.classList.add('visible')}
onBlur={(e) => e.target.classList.remove('visible')}
>
Skip to main content
</a>
);
};
// Focus trap for modals
const useFocusTrap = (isActive) => {
const trapRef = useRef();
useEffect(() => {
if (!isActive) return;
const trapElement = trapRef.current;
if (!trapElement) return;
const focusableElements = trapElement.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const handleTabKey = (event) => {
if (event.key !== 'Tab') return;
if (event.shiftKey) {
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
};
trapElement.addEventListener('keydown', handleTabKey);
firstElement?.focus();
return () => {
trapElement.removeEventListener('keydown', handleTabKey);
};
}, [isActive]);
return trapRef;
};
Screen Reader Support
Optimize your React components for screen readers:
import React, { useState, useEffect } from 'react';
// Live region for dynamic content announcements
const LiveRegion = ({ message, level = 'polite' }) => {
 return (
  <div
   aria-live={level}
   aria-atomic="true"
   className="sr-only"
  >
   {message}
  </div>
 );
};
// Custom hook for announcements
const useAnnouncer = () => {
 const [announcement, setAnnouncement] = useState('');
 const announce = (message, level = 'polite') => {
  setAnnouncement(''); // Clear first to ensure re-announcement
  setTimeout(() => setAnnouncement(message), 100);
 };
 return { announcement, announce };
};
// Loading states with proper announcements
const AccessibleLoadingStates = () => {
 const [loading, setLoading] = useState(false);
 const [data, setData] = useState(null);
 const { announcement, announce } = useAnnouncer();
 const fetchData = async () => {
  setLoading(true);
  announce('Loading data, please wait');
  try {
   const response = await fetch('/api/data');
   const result = await response.json();
   setData(result);
   announce('Data loaded successfully');
  } catch (error) {
   announce('Error loading data', 'assertive');
  } finally {
   setLoading(false);
  }
 };
 return (
  <div>
   <button onClick={fetchData} disabled={loading}>
    {loading ? 'Loading...' : 'Fetch Data'}
   </button>
   {loading && (
    <div role="status" aria-label="Loading">
     <span className="sr-only">Loading data, please wait</span>
     <div className="spinner" aria-hidden="true"></div>
    </div>
   )}
   {data && (
    <div role="region" aria-label="Data results">
     <h2>Results</h2>
     <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
   )}
   <LiveRegion message={announcement} />
  </div>
 );
};
// Form validation with screen reader support
const AccessibleFormValidation = () => {
 const [email, setEmail] = useState('');
 const [errors, setErrors] = useState({});
 const { announcement, announce } = useAnnouncer();
 const validateEmail = (value) => {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!value) {
   return 'Email is required';
  }
  if (!emailRegex.test(value)) {
   return 'Please enter a valid email address';
  }
  return '';
 };
 const handleEmailChange = (e) => {
  const value = e.target.value;
  setEmail(value);
 Â
  const error = validateEmail(value);
  setErrors(prev => ({ ...prev, email: error }));
 Â
  if (error) {
   announce(error, 'assertive');
  }
 };
 const handleSubmit = (e) => {
  e.preventDefault();
  const emailError = validateEmail(email);
 Â
  if (emailError) {
   setErrors({ email: emailError });
   announce('Form has errors, please correct them', 'assertive');
   return;
  }
 Â
  announce('Form submitted successfully');
 };
 return (
  <form onSubmit={handleSubmit} noValidate>
   <div>
    <label htmlFor="email">Email Address *</label>
    <input
     id="email"
     type="email"
     value={email}
     onChange={handleEmailChange}
     aria-invalid={!!errors.email}
     aria-describedby={errors.email ? 'email-error' : undefined}
     required
    />
    {errors.email && (
     <div
      id="email-error"
      role="alert"
      className="error-message"
     >
      {errors.email}
     </div>
    )}
   </div>
   <button type="submit">Submit</button>
   <LiveRegion message={announcement} />
  </form>
 );
};
Color and Contrast
Ensure proper color contrast and don’t rely solely on color for information:
import React, { useState } from 'react';
// Status indicator with multiple visual cues
const StatusIndicator = ({ status, message }) => {
const getStatusProps = (status) => {
switch (status) {
case 'success':
return {
className: 'status-success',
icon: '✓',
ariaLabel: 'Success'
};
case 'error':
return {
className: 'status-error',
icon: '✗',
ariaLabel: 'Error'
};
case 'warning':
return {
className: 'status-warning',
icon: 'âš ',
ariaLabel: 'Warning'
};
default:
return {
className: 'status-info',
icon: 'ℹ',
ariaLabel: 'Information'
};
}
};
const statusProps = getStatusProps(status);
return (
<div
className={`status-indicator ${statusProps.className}`}
role="alert"
aria-label={statusProps.ariaLabel}
>
<span className="status-icon" aria-hidden="true">
{statusProps.icon}
</span>
<span className="status-message">{message}</span>
</div>
);
};
// Chart with alternative text representation
const AccessibleChart = ({ data, title }) => {
const [showTable, setShowTable] = useState(false);
return (
<div className="chart-container">
<h3>{title}</h3>
<div className="chart-controls">
<button onClick={() => setShowTable(!showTable)}>
{showTable ? 'Show Chart' : 'Show Data Table'}
</button>
</div>
{!showTable ? (
<div className="chart" role="img" aria-labelledby="chart-description">
{/* Chart visualization */}
<svg width="400" height="300">
{/* Chart content */}
</svg>
<div id="chart-description" className="sr-only">
Chart showing {data.length} data points.
Highest value: {Math.max(...data.map(d => d.value))}.
Lowest value: {Math.min(...data.map(d => d.value))}.
Use the "Show Data Table" button to access detailed data.
</div>
</div>
) : (
<table className="data-table">
<caption>Data for {title}</caption>
<thead>
<tr>
<th scope="col">Category</th>
<th scope="col">Value</th>
</tr>
</thead>
<tbody>
{data.map((item, index) => (
<tr key={index}>
<th scope="row">{item.label}</th>
<td>{item.value}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
};
Testing Accessibility
Implement accessibility testing in your React applications:
// accessibility.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import userEvent from '@testing-library/user-event';
expect.extend(toHaveNoViolations);
// Test component accessibility
describe('AccessibleButton', () => {
test('should be accessible', async () => {
const { container } = render(
<button onClick={() => {}} aria-label="Close dialog">
×
</button>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
test('should be keyboard navigable', async () => {
const user = userEvent.setup();
const handleClick = jest.fn();
render(
<button onClick={handleClick}>Click me</button>
);
const button = screen.getByRole('button');
await user.tab();
expect(button).toHaveFocus();
await user.keyboard('{Enter}');
expect(handleClick).toHaveBeenCalled();
});
});
// Custom accessibility testing utilities
const getAccessibilityViolations = async (component) => {
const { container } = render(component);
const results = await axe(container);
return results.violations;
};
const testKeyboardNavigation = async (component, expectedFocusOrder) => {
const user = userEvent.setup();
render(component);
for (const expectedElement of expectedFocusOrder) {
await user.tab();
expect(screen.getByRole(expectedElement.role, { name: expectedElement.name }))
.toHaveFocus();
}
};
// Integration with React Testing Library
const AccessibilityTestWrapper = ({ children }) => {
return (
<div role="main">
{children}
</div>
);
};
describe('Form Accessibility', () => {
test('form labels are properly associated', () => {
render(
<AccessibilityTestWrapper>
<form>
<label htmlFor="username">Username</label>
<input id="username" type="text" />
</form>
</AccessibilityTestWrapper>
);
const input = screen.getByLabelText('Username');
expect(input).toBeInTheDocument();
});
});
Accessibility Hooks and Utilities
Create reusable hooks for common accessibility patterns:
import { useEffect, useRef, useState } from 'react';
// Hook for managing focus
export const useFocus = () => {
const ref = useRef();
const setFocus = () => {
ref.current?.focus();
};
return [ref, setFocus];
};
// Hook for announcements
export const useScreenReaderAnnouncer = () => {
const [message, setMessage] = useState('');
const timeoutRef = useRef();
const announce = (text, priority = 'polite') => {
clearTimeout(timeoutRef.current);
setMessage('');
timeoutRef.current = setTimeout(() => {
setMessage(text);
}, 100);
};
const AnnouncerComponent = ({ className = 'sr-only' }) => (
<div aria-live="polite" aria-atomic="true" className={className}>
{message}
</div>
);
return { announce, AnnouncerComponent };
};
// Hook for escape key handling
export const useEscapeKey = (callback) => {
useEffect(() => {
const handleEscape = (event) => {
if (event.key === 'Escape') {
callback();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [callback]);
};
// Hook for detecting reduced motion preference
export const useReducedMotion = () => {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReducedMotion(mediaQuery.matches);
const handleChange = (event) => {
setPrefersReducedMotion(event.matches);
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
return prefersReducedMotion;
};
// Hook for high contrast detection
export const useHighContrast = () => {
const [isHighContrast, setIsHighContrast] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-contrast: high)');
setIsHighContrast(mediaQuery.matches);
const handleChange = (event) => {
setIsHighContrast(event.matches);
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
return isHighContrast;
};
// Usage example
const AccessibleComponent = () => {
const [focusRef, setFocus] = useFocus();
const { announce, AnnouncerComponent } = useScreenReaderAnnouncer();
const prefersReducedMotion = useReducedMotion();
useEscapeKey(() => {
announce('Escaped from component');
});
const handleClick = () => {
announce('Button was clicked');
setFocus();
};
return (
<div>
<button ref={focusRef} onClick={handleClick}>
Click me
</button>
<div
className={prefersReducedMotion ? 'no-animation' : 'with-animation'}
>
Animated content
</div>
<AnnouncerComponent />
</div>
);
};
Best Practices Summary
1. Development Checklist
- Use semantic HTML elements
- Provide proper labels and descriptions
- Ensure keyboard navigation works
- Test with screen readers
- Verify color contrast ratios
- Test with accessibility tools
2. Common Patterns
// Always provide labels
<input id="email" type="email" />
<label htmlFor="email">Email Address</label>
// Use ARIA attributes appropriately
<button aria-expanded={isOpen} aria-haspopup="true">
 Menu
</button>
// Provide alternative text
<img src="chart.png" alt="Sales increased 20% this quarter" />
// Use live regions for dynamic content
<div aria-live="polite">{status}</div>
// Ensure focus management
useEffect(() => {
 if (isModalOpen) {
  modalRef.current?.focus();
 }
}, [isModalOpen]);
3. Tools and Resources
Testing Tools:
- axe-core and jest-axe for automated testing
- React Developer Tools accessibility features
- Browser accessibility inspectors
- Screen reader testing (NVDA, JAWS, VoiceOver)
Development Tools:
- ESLint plugin for accessibility (eslint-plugin-jsx-a11y)
- React Testing Library with accessibility queries
- Storybook accessibility addon
Conclusion
Building accessible React applications requires continuous attention to detail and user experience. By following the patterns and practices outlined in this chapter, you can create applications that work for everyone. Remember that accessibility is not a one-time task but an ongoing responsibility that should be integrated into your development workflow.
Key principles to remember:
- Start with semantic HTML
- Use ARIA attributes to enhance semantics
- Ensure keyboard navigation works properly
- Provide clear feedback for all user interactions
- Test with real assistive technologies
- Consider user preferences for motion and contrast
- Implement comprehensive accessibility testing
Accessibility makes your applications better for everyone, not just users with disabilities. It improves usability, SEO, and overall user experience while ensuring compliance with legal requirements.
Key Takeaways
- Accessibility is essential for inclusive web applications
- Semantic HTML forms the foundation of accessible React components
- ARIA attributes enhance semantics for assistive technologies
- Keyboard navigation must be implemented for all interactive elements
- Screen reader support requires proper labeling and live regions
- Color and contrast considerations are crucial for visual accessibility
- Automated testing with tools like axe-core helps catch accessibility issues
- Custom hooks can simplify common accessibility patterns
- Accessibility testing should be integrated into your development workflow
In the next chapter, we’ll explore React Animations, learning how to create engaging and accessible animated user interfaces.