# Accessibility Patterns Reference ## ARIA Patterns for Common Components ### Modal Dialog ```tsx import { useEffect, useRef, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; interface ModalProps { isOpen: boolean; onClose: () => void; title: string; children: ReactNode; } export function Modal({ isOpen, onClose, title, children }: ModalProps) { const dialogRef = useRef(null); const previousActiveElement = useRef(null); useEffect(() => { if (isOpen) { previousActiveElement.current = document.activeElement; dialogRef.current?.focus(); document.body.style.overflow = 'hidden'; } else { document.body.style.overflow = ''; (previousActiveElement.current as HTMLElement)?.focus(); } return () => { document.body.style.overflow = ''; }; }, [isOpen]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); if (e.key === 'Tab') trapFocus(e, dialogRef.current); }; if (isOpen) { document.addEventListener('keydown', handleKeyDown); } return () => document.removeEventListener('keydown', handleKeyDown); }, [isOpen, onClose]); if (!isOpen) return null; return createPortal(
{/* Backdrop */} , document.body ); } function trapFocus(e: KeyboardEvent, container: HTMLElement | null) { if (!container) return; const focusableElements = container.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; if (e.shiftKey && document.activeElement === firstElement) { e.preventDefault(); lastElement.focus(); } else if (!e.shiftKey && document.activeElement === lastElement) { e.preventDefault(); firstElement.focus(); } } ``` ### Dropdown Menu ```tsx import { useState, useRef, useEffect, type ReactNode } from 'react'; interface DropdownProps { trigger: ReactNode; children: ReactNode; label: string; } export function Dropdown({ trigger, children, label }: DropdownProps) { const [isOpen, setIsOpen] = useState(false); const containerRef = useRef(null); const menuRef = useRef(null); const triggerRef = useRef(null); useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setIsOpen(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); const handleKeyDown = (e: React.KeyboardEvent) => { switch (e.key) { case 'Escape': setIsOpen(false); triggerRef.current?.focus(); break; case 'ArrowDown': e.preventDefault(); if (!isOpen) { setIsOpen(true); } else { focusNextItem(menuRef.current, 1); } break; case 'ArrowUp': e.preventDefault(); if (isOpen) { focusNextItem(menuRef.current, -1); } break; case 'Home': e.preventDefault(); focusFirstItem(menuRef.current); break; case 'End': e.preventDefault(); focusLastItem(menuRef.current); break; } }; return (
{isOpen && (
{children}
)}
); } interface MenuItemProps { children: ReactNode; onClick?: () => void; disabled?: boolean; } export function MenuItem({ children, onClick, disabled }: MenuItemProps) { return ( ); } function focusNextItem(menu: HTMLElement | null, direction: 1 | -1) { if (!menu) return; const items = menu.querySelectorAll('[role="menuitem"]:not([disabled])'); const currentIndex = Array.from(items).indexOf(document.activeElement as HTMLElement); const nextIndex = (currentIndex + direction + items.length) % items.length; items[nextIndex]?.focus(); } function focusFirstItem(menu: HTMLElement | null) { menu?.querySelector('[role="menuitem"]:not([disabled])')?.focus(); } function focusLastItem(menu: HTMLElement | null) { const items = menu?.querySelectorAll('[role="menuitem"]:not([disabled])'); items?.[items.length - 1]?.focus(); } ``` ### Combobox / Autocomplete ```tsx import { useState, useRef, useId, type ChangeEvent, type KeyboardEvent } from 'react'; interface Option { value: string; label: string; } interface ComboboxProps { options: Option[]; value: string; onChange: (value: string) => void; label: string; placeholder?: string; } export function Combobox({ options, value, onChange, label, placeholder, }: ComboboxProps) { const [isOpen, setIsOpen] = useState(false); const [inputValue, setInputValue] = useState(''); const [activeIndex, setActiveIndex] = useState(-1); const inputRef = useRef(null); const listboxRef = useRef(null); const inputId = useId(); const listboxId = useId(); const filteredOptions = options.filter((option) => option.label.toLowerCase().includes(inputValue.toLowerCase()) ); const handleInputChange = (e: ChangeEvent) => { setInputValue(e.target.value); setIsOpen(true); setActiveIndex(-1); }; const handleSelect = (option: Option) => { onChange(option.value); setInputValue(option.label); setIsOpen(false); inputRef.current?.focus(); }; const handleKeyDown = (e: KeyboardEvent) => { switch (e.key) { case 'ArrowDown': e.preventDefault(); if (!isOpen) { setIsOpen(true); } else { setActiveIndex((prev) => prev < filteredOptions.length - 1 ? prev + 1 : prev ); } break; case 'ArrowUp': e.preventDefault(); setActiveIndex((prev) => (prev > 0 ? prev - 1 : prev)); break; case 'Enter': e.preventDefault(); if (activeIndex >= 0 && filteredOptions[activeIndex]) { handleSelect(filteredOptions[activeIndex]); } break; case 'Escape': setIsOpen(false); break; } }; return (
= 0 ? `option-${activeIndex}` : undefined } value={inputValue} placeholder={placeholder} onChange={handleInputChange} onKeyDown={handleKeyDown} onFocus={() => setIsOpen(true)} onBlur={() => setTimeout(() => setIsOpen(false), 200)} className="w-full rounded-md border px-3 py-2" /> {isOpen && filteredOptions.length > 0 && (
    {filteredOptions.map((option, index) => (
  • handleSelect(option)} className={`cursor-pointer px-3 py-2 ${ activeIndex === index ? 'bg-blue-100' : 'hover:bg-gray-100' } ${value === option.value ? 'font-medium' : ''}`} > {option.label}
  • ))}
)} {isOpen && filteredOptions.length === 0 && (
No results found
)}
); } ``` ### Form Validation ```tsx import { useId, type FormEvent } from 'react'; interface FormFieldProps { label: string; error?: string; required?: boolean; children: (props: { id: string; 'aria-describedby': string | undefined; 'aria-invalid': boolean; }) => ReactNode; } export function FormField({ label, error, required, children }: FormFieldProps) { const id = useId(); const errorId = `${id}-error`; return (
{children({ id, 'aria-describedby': error ? errorId : undefined, 'aria-invalid': !!error, })} {error && ( )}
); } // Usage function ContactForm() { const [errors, setErrors] = useState>({}); const handleSubmit = (e: FormEvent) => { e.preventDefault(); // Validation logic... }; return (
{(props) => ( )}
); } ``` ## Skip Links ```tsx export function SkipLinks() { return ( ); } ``` ## Live Regions ```tsx import { useState, useEffect } from 'react'; interface LiveAnnouncerProps { message: string; politeness?: 'polite' | 'assertive'; } export function LiveAnnouncer({ message, politeness = 'polite' }: LiveAnnouncerProps) { const [announcement, setAnnouncement] = useState(''); useEffect(() => { // Clear first, then set - ensures screen readers pick up the change setAnnouncement(''); const timer = setTimeout(() => setAnnouncement(message), 100); return () => clearTimeout(timer); }, [message]); return (
{announcement}
); } // Usage in a search component function SearchResults({ results, loading }: { results: Item[]; loading: boolean }) { const message = loading ? 'Loading results...' : `${results.length} results found`; return ( <>
    {/* results */}
); } ``` ## Focus Management Utilities ```tsx // useFocusReturn - restore focus after closing function useFocusReturn() { const previousElement = useRef(null); const saveFocus = () => { previousElement.current = document.activeElement; }; const restoreFocus = () => { (previousElement.current as HTMLElement)?.focus(); }; return { saveFocus, restoreFocus }; } // useFocusTrap - keep focus within container function useFocusTrap(containerRef: RefObject, isActive: boolean) { useEffect(() => { if (!isActive || !containerRef.current) return; const container = containerRef.current; const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; const handleKeyDown = (e: KeyboardEvent) => { if (e.key !== 'Tab') return; const focusableElements = container.querySelectorAll(focusableSelector); const first = focusableElements[0]; const last = focusableElements[focusableElements.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }; container.addEventListener('keydown', handleKeyDown); return () => container.removeEventListener('keydown', handleKeyDown); }, [containerRef, isActive]); } ``` ## Color Contrast Utilities ```tsx // Check if colors meet WCAG requirements function getContrastRatio(fg: string, bg: string): number { const getLuminance = (hex: string): number => { const rgb = parseInt(hex.slice(1), 16); const r = (rgb >> 16) & 0xff; const g = (rgb >> 8) & 0xff; const b = rgb & 0xff; const [rs, gs, bs] = [r, g, b].map((c) => { c = c / 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }); return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; }; const l1 = getLuminance(fg); const l2 = getLuminance(bg); const lighter = Math.max(l1, l2); const darker = Math.min(l1, l2); return (lighter + 0.05) / (darker + 0.05); } function meetsWCAG(fg: string, bg: string, level: 'AA' | 'AAA' = 'AA'): boolean { const ratio = getContrastRatio(fg, bg); return level === 'AAA' ? ratio >= 7 : ratio >= 4.5; } ```