# Accessibility Accessibility (a11y) is not an optional feature—it's a fundamental requirement for modern web components. Every component must be usable by everyone, including people with visual, motor, auditory, or cognitive disabilities. This guide is a non-exhaustive list of accessibility principles and patterns that you should follow when building components. It's not a comprehensive guide, but it should give you a sense of the types of issues you should be aware of. If you use a linter with strong accessibility rules like [Ultracite](https://www.ultracite.ai), these types of issues will likely be caught automatically, but it's still important to understand the principles. ## Core Principles ### 1. Semantic HTML First Always start with the most appropriate HTML element. Semantic HTML provides built-in accessibility features that custom implementations often miss. ```tsx // ❌ Don't reinvent the wheel
Click me
// ✅ Use semantic elements ``` Semantic elements come with proper role announcements, keyboard interaction, focus management, and form participation. ### 2. Keyboard Navigation Every interactive element must be keyboard accessible. Users should be able to navigate, activate, and interact with all functionality using only a keyboard. ```tsx // ✅ Complete keyboard support function Menu() { const handleKeyDown = (e: React.KeyboardEvent) => { switch(e.key) { case 'ArrowDown': focusNextItem(); break; case 'ArrowUp': focusPreviousItem(); break; case 'Home': focusFirstItem(); break; case 'End': focusLastItem(); break; case 'Escape': closeMenu(); break; } }; return (
{/* menu items */}
); } ``` ### 3. Screen Reader Support Ensure all content and interactions are announced properly to screen readers using ARIA attributes when necessary. ```tsx // ✅ Proper ARIA labeling // ✅ Dynamic content announcements
{isLoading && Loading results...} {results && {results.length} results found}
``` ### 4. Visual Accessibility Support users with visual impairments through proper contrast, focus indicators, and responsive text sizing. ```css /* ✅ Visible focus indicators */ button:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; } /* ✅ Sufficient color contrast (4.5:1 for normal text, 3:1 for large text) */ .text { color: #333; /* Against white: 12.6:1 ratio */ background: white; } /* ✅ Responsive text sizing */ .text { font-size: 1rem; /* Respects user preferences */ } ``` ## ARIA Patterns ### Understanding ARIA ARIA (Accessible Rich Internet Applications) provides semantic information about elements to assistive technologies. Use ARIA to enhance, not replace, semantic HTML. It has a few rules that you should follow: 1. Don't use ARIA if you can use semantic HTML 2. Don't change native semantics unless necessary 3. All interactive elements must be keyboard accessible 4. Don't hide focusable elements from assistive technologies 5. All interactive elements must have accessible names ### Common ARIA Attributes #### Roles Define what an element is: ```tsx // Widget roles
Custom Button
// Landmark roles
{/* breadcrumb items */}
// Live region roles
Error: Invalid email address
``` #### States Describe the current state of an element: ```tsx // Checked state
Accept terms
// Expanded state // Selected state
  • Option 1
  • ``` #### Properties Provide additional information: ```tsx // Labels and descriptions Press Enter to search // Relationships // Required and invalid Please enter a valid email ``` ## Component Patterns ### Modal/Dialog Modals require careful focus management and keyboard trapping: ```tsx function Modal({ isOpen, onClose, children }) { const modalRef = useRef(null); const previousFocus = useRef(null); useEffect(() => { if (isOpen) { // Store current focus previousFocus.current = document.activeElement as HTMLElement; // Focus first focusable element in modal const firstFocusable = modalRef.current?.querySelector( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); firstFocusable?.focus(); // Prevent body scroll document.body.style.overflow = 'hidden'; } else { // Restore focus previousFocus.current?.focus(); document.body.style.overflow = ''; } return () => { document.body.style.overflow = ''; }; }, [isOpen]); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { onClose(); } if (e.key === 'Tab') { // Trap focus within modal const focusables = modalRef.current?.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); if (focusables && focusables.length > 0) { const firstFocusable = focusables[0]; const lastFocusable = focusables[focusables.length - 1]; if (e.shiftKey && document.activeElement === firstFocusable) { e.preventDefault(); lastFocusable.focus(); } else if (!e.shiftKey && document.activeElement === lastFocusable) { e.preventDefault(); firstFocusable.focus(); } } } }; if (!isOpen) return null; return (
    {children}
    ); } ``` ### Dropdown Menu Dropdowns need proper ARIA attributes and keyboard navigation: ```tsx function DropdownMenu({ items }) { const [isOpen, setIsOpen] = useState(false); const [selectedIndex, setSelectedIndex] = useState(-1); const menuRef = useRef(null); const handleKeyDown = (e: React.KeyboardEvent) => { switch(e.key) { case 'ArrowDown': e.preventDefault(); if (!isOpen) { setIsOpen(true); setSelectedIndex(0); } else { setSelectedIndex(prev => prev < items.length - 1 ? prev + 1 : 0 ); } break; case 'ArrowUp': e.preventDefault(); if (isOpen) { setSelectedIndex(prev => prev > 0 ? prev - 1 : items.length - 1 ); } break; case 'Enter': case ' ': e.preventDefault(); if (!isOpen) { setIsOpen(true); } else if (selectedIndex >= 0) { items[selectedIndex].onClick(); setIsOpen(false); } break; case 'Escape': setIsOpen(false); setSelectedIndex(-1); break; } }; return (
    {isOpen && ( )}
    ); } ``` ### Tabs Tab interfaces require specific ARIA patterns and keyboard navigation: ```tsx function Tabs({ tabs, defaultTab = 0 }) { const [activeTab, setActiveTab] = useState(defaultTab); const handleKeyDown = (e: React.KeyboardEvent, index: number) => { let newIndex = index; switch(e.key) { case 'ArrowLeft': newIndex = index > 0 ? index - 1 : tabs.length - 1; break; case 'ArrowRight': newIndex = index < tabs.length - 1 ? index + 1 : 0; break; case 'Home': newIndex = 0; break; case 'End': newIndex = tabs.length - 1; break; default: return; } e.preventDefault(); setActiveTab(newIndex); // Focus the newly selected tab const tabElement = document.getElementById(`tab-${newIndex}`); tabElement?.focus(); }; return (
    {tabs.map((tab, index) => ( ))}
    {tabs.map((tab, index) => ( ))}
    ); } ``` ### Forms Forms need clear labels, error messages, and validation feedback: ```tsx function AccessibleForm() { const [errors, setErrors] = useState({}); return (
    We'll never share your email {errors.email && ( {errors.email} )}
    Notification Preferences
    ); } ``` ## Focus Management ### Focus Visible Show focus indicators only for keyboard navigation: ```css /* Remove default outline */ *:focus { outline: none; } /* Show outline only for keyboard focus */ *:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; } /* Custom focus styles for specific components */ .button:focus-visible { box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5); } ``` ### Focus Trapping Keep focus within a specific region: ```tsx function useFocusTrap(ref: React.RefObject, isActive: boolean) { useEffect(() => { if (!isActive || !ref.current) return; const element = ref.current; const focusableSelector = 'a[href], button, textarea, input[type="text"], input[type="radio"], input[type="checkbox"], select, [tabindex]:not([tabindex="-1"])'; const focusableElements = element.querySelectorAll(focusableSelector); const firstFocusable = focusableElements[0]; const lastFocusable = focusableElements[focusableElements.length - 1]; const handleTabKey = (e: KeyboardEvent) => { if (e.key !== 'Tab') return; if (e.shiftKey) { if (document.activeElement === firstFocusable) { e.preventDefault(); lastFocusable?.focus(); } } else { if (document.activeElement === lastFocusable) { e.preventDefault(); firstFocusable?.focus(); } } }; element.addEventListener('keydown', handleTabKey); firstFocusable?.focus(); return () => { element.removeEventListener('keydown', handleTabKey); }; }, [ref, isActive]); } ``` ### Focus Restoration Return focus to the appropriate element after interactions: ```tsx function useRestoreFocus() { const previousFocus = useRef(null); const saveFocus = () => { previousFocus.current = document.activeElement as HTMLElement; }; const restoreFocus = () => { previousFocus.current?.focus(); }; return { saveFocus, restoreFocus }; } ``` ## Live Regions Announce dynamic content changes to screen readers: ### Status Messages ```tsx // Polite announcement (waits for screen reader to finish)
    {savedMessage && "Settings saved successfully"}
    // Assertive announcement (interrupts screen reader)
    {errorMessage && `Error: ${errorMessage}`}
    // Loading states
    {isLoading ? "Loading..." : `${items.length} items loaded`}
    ``` ### Progress Indicators ```tsx function ProgressBar({ value, max = 100 }) { return (
    {Math.round((value / max) * 100)}% complete
    ); } ``` ## Color and Contrast ### Contrast Requirements Follow WCAG guidelines for color contrast: ```css /* Normal text (< 18pt or < 14pt bold) */ .text { color: #595959; /* 7:1 ratio against white */ background: white; } /* Large text (≥ 18pt or ≥ 14pt bold) */ .heading { color: #767676; /* 4.5:1 ratio against white */ font-size: 1.5rem; font-weight: bold; } /* Non-text elements (icons, borders) */ .icon { color: #949494; /* 3:1 ratio against white */ } ``` ### Color Independence Never convey information through color alone: ```tsx // ❌ Color only Error // ✅ Color with text/icon // ✅ Multiple indicators {hasError && ( )} ``` ## Mobile Accessibility ### Touch Targets Ensure touch targets are large enough: ```css /* Minimum 44x44px for iOS, 48x48dp for Android */ .button { min-height: 44px; min-width: 44px; padding: 12px 16px; } /* Add invisible touch area for small icons */ .icon-button { position: relative; padding: 8px; } .icon-button::before { content: ''; position: absolute; top: -8px; right: -8px; bottom: -8px; left: -8px; } ``` ### Viewport and Zoom Allow users to zoom: ```html ``` ## Common Pitfalls ### Placeholder Text as Labels Don't use placeholders as the only label: ```tsx // ❌ Placeholder disappears when typing // ✅ Persistent label // ✅ Floating label pattern
    ``` ### Empty Buttons Always provide accessible text for icon buttons: ```tsx // ❌ No accessible name // ✅ Screen reader text // ✅ Visually hidden text ``` ### Disabled Form Elements Disabled elements aren't focusable, which can confuse users: ```tsx // ❌ User can't understand why button is disabled // ✅ Use aria-disabled and explain {!isValid && 'Please fill in all required fields'} ``` # asChild The `asChild` prop is a powerful pattern in modern React component libraries. Popularized by [Radix UI](https://www.radix-ui.com/primitives/docs/guides/composition) and adopted by [shadcn/ui](https://ui.shadcn.com), this pattern allows you to replace default markup with custom elements while maintaining the component's functionality. ## Understanding `asChild` At its core, `asChild` changes how a component renders. When set to `true`, instead of rendering its default DOM element, the component merges its props, behaviors, and event handlers with its immediate child element. ### Without `asChild` ```tsx ``` This renders nested elements: ```html ``` ### With `asChild` ```tsx ``` This renders a single, merged element: ```html ``` The Dialog.Trigger's functionality is composed onto your button, eliminating unnecessary wrapper elements. ## How It Works Under the hood, `asChild` uses React's composition capabilities to merge components: ```tsx // Simplified implementation function Component({ asChild, children, ...props }) { if (asChild) { // Clone child and merge props return React.cloneElement(children, { ...props, ...children.props, // Merge event handlers onClick: (e) => { props.onClick?.(e); children.props.onClick?.(e); } }); } // Render default element return ; } ``` The component: 1. Checks if `asChild` is true 2. Clones the child element 3. Merges props from both parent and child 4. Combines event handlers 5. Returns the enhanced child ## Key Benefits ### 1. Semantic HTML `asChild` lets you use the most appropriate HTML element for your use case: ```tsx // Use a link for navigation Delete Account // Use a custom button component } /> ``` ### 2. Clean DOM Structure Traditional composition often creates deeply nested DOM structures. `asChild` eliminates this "wrapper hell": ```tsx // Without asChild: Nested wrappers // With asChild: Clean structure ``` ### 3. Design System Integration `asChild` enables seamless integration with your existing design system components: ```tsx import { Button } from '@/components/ui/button'; ``` Your Button component receives all the necessary dropdown trigger behavior without modification. ### 4. Component Composition You can compose multiple behaviors onto a single element: ```tsx ``` This creates a button that both opens a dialog and shows a tooltip on hover. ## Common Use Cases ### Custom Trigger Elements Replace default triggers with custom components: ```tsx // Custom link trigger Toggle Details // Icon-only trigger ``` ### Accessible Navigation Maintain proper semantics for navigation elements: ```tsx Products ``` ### Form Integration Integrate with form libraries while preserving functionality: ```tsx ( )} /> ``` ## Best Practices ### 1. Maintain Accessibility When changing element types, ensure accessibility is preserved: ```tsx // ✅ Good - maintains button semantics // ⚠️ Caution - ensure proper ARIA attributes
    Open
    ``` ### 2. Document Component Requirements Clearly document when components support `asChild`: ```tsx interface TriggerProps { /** * Change the default rendered element for the one passed as a child, * merging their props and behavior. * * @default false */ asChild?: boolean; children: React.ReactNode; } ``` ### 3. Test Child Components Verify that custom components work correctly with `asChild`: ```tsx // Test that props are properly forwarded const TestButton = (props) => { console.log('Received props:', props); return )} ``` ## Common Pitfalls ### Not Spreading Props As discussed in [Types](/docs/types), you should always spread props to the underlying element. ```tsx // ❌ Won't receive trigger behavior const BadButton = ({ children }) => ; // ✅ Properly receives all props const GoodButton = ({ children, ...props }) => ( ); ``` ### Multiple Children Don't pass multiple children to a component that supports `asChild`. This will cause an error as the component will not know which child to use. ```tsx // ❌ Error - asChild expects single child // ✅ Single child element ``` ### Fragment Children Don't pass a fragment to a component that supports `asChild`. This will cause an error as fragments are not valid elements. ```tsx // ❌ Fragment is not a valid element <>Button // ✅ Actual element ``` # Composition Composition, or composability, is the foundation of building modern UI components. It is one of the most powerful techniques for creating flexible, reusable components that can handle complex requirements without sacrificing API clarity. Instead of cramming all functionality into a single component with dozens of props, composition distributes responsibility across multiple cooperating components. Fernando gave a great talk about this at React Universe Conf 2025, where he shared his approach to rebuilding Slack's Message Composer as a composable component.