Web Accessibility in Modern React Applications
Complete guide to making React applications accessible. Learn about WCAG guidelines, semantic HTML, ARIA attributes, and testing for accessibility.
Web accessibility ensures that your React applications are usable by everyone, including people with disabilities. This comprehensive guide covers WCAG guidelines, semantic HTML, ARIA attributes, and testing strategies.
Understanding WCAG Guidelines
The Web Content Accessibility Guidelines (WCAG) 2.1 provide the foundation for web accessibility. They are organized around four principles: Perceivable, Operable, Understandable, and Robust (POUR).
- 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 and ARIA
interface FormFieldProps { label: string; id: string; type?: string; required?: boolean; error?: string; helpText?: string; value: string; onChange: (value: string) => void;}export function AccessibleFormField({ label, id, type = 'text', required = false, error, helpText, value, onChange}: FormFieldProps) { const helpTextId = helpText ? `${id}-help` : undefined; const errorId = error ? `${id}-error` : undefined; return ( <div className="form-field"> <label htmlFor={id} className="block text-sm font-medium text-gray-700" > {label} {required && ( <span className="text-red-500 ml-1" aria-label="required" > * </span> )} </label> <input id={id} type={type} required={required} value={value} onChange={e => onChange(e.target.value)} aria-describedby={[helpTextId, errorId].filter(Boolean).join(' ') || undefined} aria-invalid={!!error} className={` mt-1 block w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${error ? 'border-red-500' : 'border-gray-300'} `} /> {helpText && ( <p id={helpTextId} className="mt-1 text-sm text-gray-600"> {helpText} </p> )} {error && ( <p id={errorId} role="alert" className="mt-1 text-sm text-red-600" > {error} </p> )} </div> );}
Keyboard Navigation
import { useEffect, useRef } from 'react';import { createPortal } from 'react-dom';interface ModalProps { isOpen: boolean; onClose: () => void; title: string; children: React.ReactNode;}export function AccessibleModal({ isOpen, onClose, title, children }: ModalProps) { const modalRef = useRef<HTMLDivElement>(null); const previousActiveElement = useRef<HTMLElement | null>(null); useEffect(() => { if (isOpen) { // Store the currently focused element previousActiveElement.current = document.activeElement as HTMLElement; // Focus the modal modalRef.current?.focus(); // Prevent body scroll document.body.style.overflow = 'hidden'; } else { // Restore focus to the previously focused element previousActiveElement.current?.focus(); // Restore body scroll document.body.style.overflow = 'unset'; } return () => { document.body.style.overflow = 'unset'; }; }, [isOpen]); const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Escape') { onClose(); } // Trap focus within modal if (event.key === 'Tab') { const focusableElements = modalRef.current?.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); if (focusableElements && focusableElements.length > 0) { const firstElement = focusableElements[0] as HTMLElement; const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement; if (event.shiftKey && document.activeElement === firstElement) { event.preventDefault(); lastElement.focus(); } else if (!event.shiftKey && document.activeElement === lastElement) { event.preventDefault(); firstElement.focus(); } } } }; if (!isOpen) return null; return createPortal( <div className="fixed inset-0 z-50 flex items-center justify-center" role="dialog" aria-modal="true" aria-labelledby="modal-title" > {/* Backdrop */} <div className="fixed inset-0 bg-black bg-opacity-50" onClick={onClose} aria-hidden="true" /> {/* Modal */} <div ref={modalRef} className="relative bg-white rounded-lg p-6 max-w-md w-full mx-4 shadow-lg" onKeyDown={handleKeyDown} tabIndex={-1} > <div className="flex justify-between items-center mb-4"> <h2 id="modal-title" className="text-lg font-semibold"> {title} </h2> <button onClick={onClose} className="text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded" aria-label="Close modal" > <span aria-hidden="true">×</span> </button> </div> <div className="modal-content"> {children} </div> </div> </div>, document.body );}
Screen Reader Support
interface TableData { id: string; name: string; email: string; role: string; status: 'active' | 'inactive';}interface AccessibleTableProps { data: TableData[]; caption: string; onSort: (column: keyof TableData) => void; sortColumn?: keyof TableData; sortDirection?: 'asc' | 'desc';}export function AccessibleDataTable({ data, caption, onSort, sortColumn, sortDirection}: AccessibleTableProps) { const getSortButtonLabel = (column: keyof TableData) => { if (sortColumn === column) { return `Sort by ${column} ${sortDirection === 'asc' ? 'descending' : 'ascending'}`; } return `Sort by ${column}`; }; const getSortIcon = (column: keyof TableData) => { if (sortColumn !== column) { return <span aria-hidden="true">↕</span>; } return sortDirection === 'asc' ? <span aria-hidden="true">↑</span> : <span aria-hidden="true">↓</span>; }; return ( <table className="w-full border-collapse border border-gray-300"> <caption className="sr-only"> {caption} </caption> <thead> <tr className="bg-gray-50"> {(['name', 'email', 'role', 'status'] as const).map(column => ( <th key={column} scope="col" className="border border-gray-300 px-4 py-2 text-left" > <button onClick={() => onSort(column)} className="flex items-center gap-1 font-semibold hover:text-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded" aria-label={getSortButtonLabel(column)} > {column.charAt(0).toUpperCase() + column.slice(1)} {getSortIcon(column)} </button> </th> ))} </tr> </thead> <tbody> {data.map(row => ( <tr key={row.id} className="hover:bg-gray-50"> <td className="border border-gray-300 px-4 py-2"> {row.name} </td> <td className="border border-gray-300 px-4 py-2"> {row.email} </td> <td className="border border-gray-300 px-4 py-2"> {row.role} </td> <td className="border border-gray-300 px-4 py-2"> <span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${ row.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' }`} aria-label={`Status: ${row.status}`} > {row.status} </span> </td> </tr> ))} </tbody> </table> );}
Accessibility Testing
Regular accessibility testing is crucial for maintaining an inclusive application. Use both automated tools and manual testing techniques.
import { render, screen } from '@testing-library/react';import { axe, toHaveNoViolations } from 'jest-axe';import userEvent from '@testing-library/user-event';import { AccessibleModal } from './AccessibleModal';// Extend Jest matchersexpect.extend(toHaveNoViolations);describe('AccessibleModal', () => { it('should not have accessibility violations', async () => { const { container } = render( <AccessibleModal isOpen={true} onClose={() => {}} title="Test Modal"> <p>Modal content</p> <button>Action Button</button> </AccessibleModal> ); const results = await axe(container); expect(results).toHaveNoViolations(); }); it('should trap focus within modal', async () => { const user = userEvent.setup(); const onClose = jest.fn(); render( <AccessibleModal isOpen={true} onClose={onClose} title="Test Modal"> <button data-testid="first-button">First Button</button> <button data-testid="second-button">Second Button</button> </AccessibleModal> ); const firstButton = screen.getByTestId('first-button'); const secondButton = screen.getByTestId('second-button'); const closeButton = screen.getByLabelText('Close modal'); // Test forward tab cycling firstButton.focus(); await user.tab(); expect(secondButton).toHaveFocus(); await user.tab(); expect(closeButton).toHaveFocus(); await user.tab(); expect(firstButton).toHaveFocus(); // Should cycle back // Test backward tab cycling await user.tab({ shift: true }); expect(closeButton).toHaveFocus(); }); it('should close on Escape key', async () => { const user = userEvent.setup(); const onClose = jest.fn(); render( <AccessibleModal isOpen={true} onClose={onClose} title="Test Modal"> <p>Modal content</p> </AccessibleModal> ); await user.keyboard('{Escape}'); expect(onClose).toHaveBeenCalled(); });});
Common Accessibility Patterns
- Use semantic HTML elements (button, nav, main, aside, etc.)
- Provide alternative text for images with meaningful content
- Ensure sufficient color contrast (4.5:1 for normal text)
- Make all interactive elements keyboard accessible
- Use ARIA labels and descriptions where needed
- Implement skip links for keyboard navigation
- Test with screen readers and keyboard-only navigation
- Provide clear error messages and form validation
Use proper HTML elements for better screen reader support and structure.
Enhance accessibility with proper ARIA labels, roles, and properties.
Ensure all interactive elements are accessible via keyboard.
Automated and manual testing for WCAG compliance.
“Accessibility is not a feature you add at the end—it should be built into your development process from the beginning. When you design for accessibility, you create better experiences for everyone.”