Build accessible React apps using semantic HTML, ARIA attributes, and keyboard navigation.
// Bad: divs with no meaning
<div onClick={() => setOpen(!open)}>Menu</div>
// Good: semantic button element
<button onClick={() => setOpen(!open)}>Menu</button><h1>Main Title</h1>
<section>
<h2>Section Title</h2>
<p>Content</p>
</section><header>Site Header</header>
<nav>Navigation Links</nav>
<main>Page Content</main>
<footer>Site Footer</footer><ul>
<li><a href="/about">About</a></li>
<li><a href="/contact">Contact</a></li>
</ul>// Button triggers an action — use <button>
<button onClick={openModal}>Open Settings</button>
// Link navigates — use <a>
<a href="/profile">View Profile</a><button aria-label="Close menu">×</button>
<button aria-label="Delete item Alice">Delete</button><div aria-live="polite" aria-atomic="true">
{statusMessage}
</div><div role="button" onClick={handleClick} tabIndex="0">
Custom Button
</div><button
aria-expanded={isOpen}
aria-controls="menu-list"
onClick={() => setIsOpen(!isOpen)}
>
Menu
</button><input
id="password"
type="password"
aria-describedby="password-hint"
/>
<p id="password-hint">Must be at least 8 characters.</p><label htmlFor="email">Email:</label>
<input id="email" type="email" /><input type="email" />
<input type="password" />
<input type="date" /><input
id="email"
type="email"
aria-invalid={!!error}
aria-errormessage="email-error"
/>
{error && <p id="email-error" role="alert">{error}</p>}<label htmlFor="name">Name <span aria-hidden="true">*</span></label>
<input id="name" type="text" required aria-required="true" /><fieldset>
<legend>Notification preferences</legend>
<label><input type="checkbox" name="email" /> Email</label>
<label><input type="checkbox" name="sms" /> SMS</label>
</fieldset>// Buttons and links are keyboard accessible by default
<button onClick={handleClick}>Click me</button>
<a href="/next">Next page</a><div
role="button"
tabIndex="0"
onClick={handleClick}
onKeyDown={(e) => e.key === "Enter" && handleClick()}
>
Custom Button
</div>function handleKeyDown(e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleClick();
}
}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;
}// Remove decorative icon from keyboard navigation
<span tabIndex="-1" aria-hidden="true">★</span>macOS: VoiceOver (Cmd + F5)
Windows: NVDA (free download at nvaccess.org)
iOS: VoiceOver (Settings > Accessibility)
Android: TalkBack (Settings > Accessibility)<div aria-live="polite" aria-atomic="true">
{loadingMessage}
</div>{error && (
<div role="alert">
{error}
</div>
)}<img src="decorative-bg.png" alt="" aria-hidden="true" />
<span aria-hidden="true">→</span>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();
});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.