Accessibility Patterns & Best Practices
Build accessible React applications following WCAG 2.1 guidelines. Learn ARIA patterns, keyboard navigation, focus management, screen reader optimization, and inclusive design practices that make your apps usable by everyone.
🏷️ 1. ARIA Patterns
🦮 ARIA attributes are like giving your HTML superpowers for assistive tech! They tell screen readers "hey, this is a dialog" or "this button opens a menu." Without them, your beautiful modal is just a mysterious div to someone using a screen reader. 🤷♂️✨
🔴 Impact: CRITICAL — Proper ARIA is the difference between an app that works for everyone and one that locks out millions of users!
📋 In this section: role & aria-modal • aria-labelledby & aria-describedby • Focus restoration • Accessible modal pattern
// ❌ WRONG: Missing ARIA attributesconst Modal = ({ isOpen, onClose, children }) => {if (!isOpen) return null;return (<div className="modal"><button onClick={onClose}>×</button>{children}</div>);};
// ✅ CORRECT: Proper ARIA attributesimport { useEffect, useRef } from 'react';const Modal = ({ isOpen, onClose, children, title }: ModalProps) => {const modalRef = useRef<HTMLDivElement>(null);const previousFocusRef = useRef<HTMLElement | null>(null);useEffect(() => {if (isOpen) {// Store previously focused elementpreviousFocusRef.current = document.activeElement as HTMLElement;// Focus modal when openedmodalRef.current?.focus();} else {// Restore focus when closedpreviousFocusRef.current?.focus();}}, [isOpen]);if (!isOpen) return null;return (<divref={modalRef}role="dialog"aria-modal="true"aria-labelledby="modal-title"aria-describedby="modal-description"tabIndex={-1}className="modal"><div className="modal-header"><h2 id="modal-title">{title}</h2><buttononClick={onClose}aria-label="Close dialog">×</button></div><div id="modal-description" className="modal-content">{children}</div></div>);};
🎯 3. Focus Management
🔒 When a modal opens, focus should be trapped inside — not wandering around behind it like a lost tourist! 🧳 And when it closes? Focus goes RIGHT back where it was. It's the little details that separate good apps from great ones! ✨
🟠 Impact: HIGH — Bad focus management makes modals and dialogs completely unusable for keyboard and screen reader users!
📋 In this section: useFocusTrap hook • Focus restoration • Skip-to-content links • Visible focus indicators
// ✅ Focus trap for modalimport { useEffect, useRef } from 'react';function useFocusTrap(isActive: boolean) {const containerRef = useRef<HTMLDivElement>(null);useEffect(() => {if (!isActive || !containerRef.current) return;const container = containerRef.current;const focusableElements = container.querySelectorAll('a[href], button:not([disabled]), textarea, input:not([disabled]), select, [tabindex]:not([tabindex="-1"])');const firstElement = focusableElements[0] as HTMLElement;const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;const handleTab = (e: KeyboardEvent) => {if (e.key !== 'Tab') return;if (e.shiftKey) {// Shift + Tabif (document.activeElement === firstElement) {e.preventDefault();lastElement.focus();}} else {// Tabif (document.activeElement === lastElement) {e.preventDefault();firstElement.focus();}}};firstElement?.focus();container.addEventListener('keydown', handleTab);return () => {container.removeEventListener('keydown', handleTab);};}, [isActive]);return containerRef;}// Usagefunction Modal({ isOpen, onClose }: ModalProps) {const containerRef = useFocusTrap(isOpen);return (<div ref={containerRef} role="dialog" aria-modal="true">{/* Modal content */}</div>);}// ✅ Restore focusfunction useRestoreFocus() {const previousFocusRef = useRef<HTMLElement | null>(null);const saveFocus = () => {previousFocusRef.current = document.activeElement as HTMLElement;};const restoreFocus = () => {previousFocusRef.current?.focus();};return { saveFocus, restoreFocus };}// ✅ Skip to content linkfunction SkipToContent() {return (<ahref="#main-content"className="skip-link"style={{position: 'absolute',top: '-40px',left: 0,background: '#000',color: '#fff',padding: '8px',textDecoration: 'none',zIndex: 100,}}onFocus={(e) => {e.currentTarget.style.top = '0';}}onBlur={(e) => {e.currentTarget.style.top = '-40px';}}>Skip to main content</a>);}
🔊 4. Screen Reader Optimization
🗣️ Your app should tell a story — even when it's being read aloud! Live regions announce dynamic changes ("3 items added to cart!" 🛒), proper labels describe buttons, and sr-only text adds context without cluttering the visual UI. It's like subtitles for your app! 📺
🟠 Impact: HIGH — Screen reader users rely entirely on these patterns to understand and interact with your application!
📋 In this section: aria-live regions • Loading state announcements • Screen reader only text • Accessible form labels
// ✅ Live regions for dynamic contentfunction Toast({ message, type }: ToastProps) {return (<divrole="alert"aria-live="assertive"aria-atomic="true"className={`toast toast-${type}`}>{message}</div>);}// ✅ Status updatesfunction StatusMessage({ status }: { status: string }) {return (<divrole="status"aria-live="polite"aria-atomic="true"className="status-message">{status}</div>);}// ✅ Loading statesfunction LoadingButton({ isLoading, children, ...props }: ButtonProps) {return (<button{...props}aria-busy={isLoading}aria-disabled={isLoading}disabled={isLoading}>{isLoading ? (<><span className="sr-only">Loading</span><Spinner aria-hidden="true" /></>) : (children)}</button>);}// ✅ Screen reader only textconst srOnly = {position: 'absolute',width: '1px',height: '1px',padding: 0,margin: '-1px',overflow: 'hidden',clip: 'rect(0, 0, 0, 0)',whiteSpace: 'nowrap',borderWidth: 0,} as const;function IconButton({ icon, label, ...props }: IconButtonProps) {return (<button {...props} aria-label={label}><Icon aria-hidden="true">{icon}</Icon><span style={srOnly}>{label}</span></button>);}// ✅ Form labelsfunction FormField({ id, label, error, required, ...props }: FormFieldProps) {return (<div><label htmlFor={id}>{label}{required && (<span aria-label="required">*</span>)}</label><inputid={id}aria-required={required}aria-invalid={!!error}aria-describedby={error ? `${id}-error` : undefined}{...props}/>{error && (<div id={`${id}-error`} role="alert" className="error-message">{error}</div>)}</div>);}
🏗️ 5. Semantic HTML
🍜 Div soup is NOT on the menu! 🚫 Using proper semantic elements like <nav>, <main>, <article>, and <section> gives your HTML meaning. Screen readers use landmarks to navigate, and search engines love structured content. It's a win-win-win! 🏆🏆🏆
🔵 Impact: MEDIUM — Semantic HTML is the foundation of accessibility — get this right and everything else becomes easier!
📋 In this section: Semantic elements vs divs • Landmark regions • Proper heading hierarchy • Navigation patterns
// ❌ WRONG: Div soupfunction App() {return (<div><div className="header"><div className="logo">Logo</div><div className="nav"><div onClick={goHome}>Home</div><div onClick={goAbout}>About</div></div></div><div className="main"><div className="article"><div className="title">Article Title</div><div className="content">Content</div></div></div></div>);}
// ✅ CORRECT: Semantic HTMLfunction App() {return (<><header><img src="/logo.png" alt="Company Logo" /><nav aria-label="Main navigation"><ul><li><a href="/">Home</a></li><li><a href="/about">About</a></li></ul></nav></header><main><article><h1>Article Title</h1><p>Content</p></article></main><footer><p>© 2024 Company</p></footer></>);}// ✅ Landmark regionsfunction Layout({ children }: { children: React.ReactNode }) {return (<><header role="banner"><SkipToContent /><Navigation /></header><main role="main" id="main-content">{children}</main><aside role="complementary" aria-label="Related articles"><RelatedArticles /></aside><footer role="contentinfo"><FooterContent /></footer></>);}
📋 6. Form Accessibility
✍️ Forms are the #1 interaction point in most apps — and the #1 accessibility pain point! 😬 Proper labels, fieldset grouping, error announcements, and aria-describedby hints make the difference between a form that's a joy to fill out and one that's impossible for screen reader users. Let's make forms accessible for ALL! 🌍🤝
🔴 Impact: CRITICAL — Inaccessible forms literally prevent users from signing up, checking out, or contacting you!
📋 In this section: Label & htmlFor pairing • Fieldset & legend grouping • Error summary patterns • aria-describedby hints
// ✅ Accessible formfunction AccessibleForm() {const [errors, setErrors] = useState<Record<string, string>>({});return (<formonSubmit={handleSubmit}noValidatearia-label="User registration form"><fieldset><legend>Personal Information</legend><div><label htmlFor="firstName">First Name <span aria-label="required">*</span></label><inputid="firstName"name="firstName"type="text"requiredaria-required="true"aria-invalid={!!errors.firstName}aria-describedby={errors.firstName ? 'firstName-error' : undefined}/>{errors.firstName && (<div id="firstName-error" role="alert" className="error">{errors.firstName}</div>)}</div><div><label htmlFor="email">Email <span aria-label="required">*</span></label><inputid="email"name="email"type="email"requiredaria-required="true"aria-invalid={!!errors.email}aria-describedby={errors.email ? 'email-error email-hint' : 'email-hint'}/><div id="email-hint" className="hint">We'll never share your email</div>{errors.email && (<div id="email-error" role="alert" className="error">{errors.email}</div>)}</div></fieldset><fieldset><legend>Preferences</legend><div role="group" aria-labelledby="newsletter-label"><div id="newsletter-label">Newsletter</div><label><input type="checkbox" name="newsletter" />Subscribe to newsletter</label></div></fieldset><button type="submit">Submit</button></form>);}// ✅ Error summaryfunction FormWithErrorSummary({ errors }: { errors: Record<string, string> }) {const errorEntries = Object.entries(errors);return (<form>{errorEntries.length > 0 && (<div role="alert" className="error-summary" aria-labelledby="error-summary-title"><h2 id="error-summary-title">Please fix the following errors:</h2><ul>{errorEntries.map(([field, message]) => (<li key={field}><a href={`#${field}`}>{message}</a></li>))}</ul></div>)}{/* Form fields */}</form>);}