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>);}