React Accessibility
Build accessible React apps using semantic HTML, ARIA attributes, and keyboard navigation.
TL;DR
- 01Use semantic HTML elements to give the browser meaningful structure.
- 02Add ARIA attributes only when semantic HTML is not enough.
- 03Test every interactive element with keyboard and screen readers.
Tips
- 01Use semantic HTML first — it covers 80% of accessibility requirements without any extra ARIA attributes or JavaScript.
Warnings
- 01ARIA is not a substitute for semantic HTML — use it to enhance, not replace proper HTML structure.
Semantic HTML
- Use semantic elements instead of divs.
// Bad: divs with no meaning <div onClick={() => setOpen(!open)}>Menu</div> // Good: semantic button element <button onClick={() => setOpen(!open)}>Menu</button> - Use proper heading hierarchy to structure pages.
<h1>Main Title</h1> <section> <h2>Section Title</h2> <p>Content</p> </section> - Use landmark elements for page regions.
<header>Site Header</header> <nav>Navigation Links</nav> <main>Page Content</main> <footer>Site Footer</footer> - Use lists for groups of related items.
<ul> <li><a href="/about">About</a></li> <li><a href="/contact">Contact</a></li> </ul> - Use the button element for any clickable action.
// Button triggers an action — use <button> <button onClick={openModal}>Open Settings</button> // Link navigates — use <a> <a href="/profile">View Profile</a>
ARIA Attributes
- Use aria-label when the visible text doesn't describe the element.
<button aria-label="Close menu">×</button> <button aria-label="Delete item Alice">Delete</button> - Use aria-live to announce dynamic content to screen readers.
<div aria-live="polite" aria-atomic="true"> {statusMessage} </div> - Use role to define the purpose of a custom element.
<div role="button" onClick={handleClick} tabIndex="0"> Custom Button </div> - Use aria-expanded to indicate open or closed state.
<button aria-expanded={isOpen} aria-controls="menu-list" onClick={() => setIsOpen(!isOpen)} > Menu </button> - Use aria-describedby to link help text to an input.
<input id="password" type="password" aria-describedby="password-hint" /> <p id="password-hint">Must be at least 8 characters.</p>
Forms and Labels
- Link every input to a label using htmlFor and id.
<label htmlFor="email">Email:</label> <input id="email" type="email" /> - Use specific input types for built-in validation.
<input type="email" /> <input type="password" /> <input type="date" /> - Show validation errors with aria-invalid and aria-errormessage.
<input id="email" type="email" aria-invalid={!!error} aria-errormessage="email-error" /> {error && <p id="email-error" role="alert">{error}</p>} - Mark required fields with required and aria-required.
<label htmlFor="name">Name <span aria-hidden="true">*</span></label> <input id="name" type="text" required aria-required="true" /> - Use fieldset and legend to group related form controls.
<fieldset> <legend>Notification preferences</legend> <label><input type="checkbox" name="email" /> Email</label> <label><input type="checkbox" name="sms" /> SMS</label> </fieldset>
Keyboard Navigation
- Make interactive elements keyboard accessible by default.
// Buttons and links are keyboard accessible by default <button onClick={handleClick}>Click me</button> <a href="/next">Next page</a> - Use tabIndex="0" to make custom elements focusable.
<div role="button" tabIndex="0" onClick={handleClick} onKeyDown={(e) => e.key === "Enter" && handleClick()} > Custom Button </div> - Handle both Enter and Space for custom button elements.
function handleKeyDown(e) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleClick(); } } - Trap focus inside modals when they are open.
function Modal({ isOpen, onClose, children }) { const firstFocusRef = useRef(null); useEffect(() => { if (isOpen) firstFocusRef.current?.focus(); }, [isOpen]); return isOpen ? ( <div role="dialog" aria-modal="true"> <button ref={firstFocusRef} onClick={onClose}>Close</button> {children} </div> ) : null; } - Use tabIndex="-1" to remove elements from tab order.
// Remove decorative icon from keyboard navigation <span tabIndex="-1" aria-hidden="true">★</span>
Screen Reader Testing
- Test with built-in screen readers on each platform.
macOS: VoiceOver (Cmd + F5) Windows: NVDA (free download at nvaccess.org) iOS: VoiceOver (Settings > Accessibility) Android: TalkBack (Settings > Accessibility) - Use aria-live for dynamic status messages.
<div aria-live="polite" aria-atomic="true"> {loadingMessage} </div> - Use role="alert" for urgent messages that need immediate attention.
{error && ( <div role="alert"> {error} </div> )} - Hide decorative elements from screen readers with aria-hidden.
<img src="decorative-bg.png" alt="" aria-hidden="true" /> <span aria-hidden="true">→</span> - Use jest-axe to automate accessibility checks in tests.
import { axe, toHaveNoViolations } from 'jest-axe'; expect.extend(toHaveNoViolations); test('has no accessibility violations', async () => { const { container } = render(<MyForm />); const results = await axe(container); expect(results).toHaveNoViolations(); });
FAQ
Use ARIA roles only when you cannot use a native HTML element that provides the same semantics — for example, when building a custom dropdown or tab component with div elements. Native elements like button, nav, and input already carry the correct role, so adding redundant ARIA roles on them is unnecessary and can cause conflicts.
Pass an id prop to your input and a matching htmlFor prop (not for) to your label element. For custom components that wrap native inputs, forward the id down to the underlying input element so the browser can establish the association.
Divs are not focusable and not in the tab order by default, so keyboard users cannot reach or activate them. Replace the div with a button element, which gets focus, Enter/Space handling, and the correct role for free — or add tabIndex={0}, onKeyDown handling, and role='button' if you must use a non-semantic element.
Use NVDA (Windows) or VoiceOver (Mac, built-in) paired with your app running locally — navigate through every interactive element using Tab and arrow keys and confirm the announced text matches the visual label. The axe DevTools browser extension can also catch the majority of ARIA and labeling issues automatically during development.
Use aria-label to provide an inline string label when no visible text label exists, such as an icon-only button. Use aria-labelledby when a visible element already contains the label text — point it to that element's id so screen readers read existing content instead of duplicating it in code.