React Accessibility (a11y): The 8 Patterns You Keep Getting Wrong
Stop shipping inaccessible React apps. Here are the 8 a11y patterns developers get wrong most often — with fixes you can apply today.
Why React Makes a11y Harder Than It Should Be
React's component model is powerful. It's also the reason you can accidentally build an app that a screen reader user simply cannot navigate. When you're composing UIs from dozens of isolated components, it's easy for the accessible tree to become a mess — missing labels here, broken focus flows there, ARIA attributes that contradict each other.
WCAG 2.1 (published in 2018) hasn't changed as dramatically as the JavaScript ecosystem has, yet most React codebases still fail basic Level AA checks. That's not a tooling problem. It's a pattern problem.
Honestly, most of these mistakes aren't hard to fix once you know what to look for. This article walks through the eight patterns that trip up experienced developers — not beginners fumbling with JSX for the first time, but people who've been shipping React apps for years.
Pattern 1: Using div and span as Interactive Elements
This is the most common one. You see a design spec, you reach for a div, you slap an onClick on it, and you move on. The component works fine with a mouse. Keyboard users and screen reader users? They're stuck.
Buttons and anchor tags get keyboard focus, Enter/Space handling, and role announcements for free. A div gets none of that. You'd have to add tabIndex={0}, role="button", onKeyDown, and still miss edge cases. Why do that when <button> already handles it?
// Wrong
<div onClick={handleSubmit} className="btn">Submit</div>
// Right
<button type="button" onClick={handleSubmit} className="btn">
Submit
</button>One more thing — if it navigates somewhere, use <a>. If it triggers an action, use <button>. That distinction matters both for semantics and for how browsers expose elements to assistive tech.
Pattern 2: Missing or Broken Form Labels
Placeholder text is not a label. Full stop. When a user focuses an input, the placeholder disappears — and now they've lost context for what the field expects. Screen readers read the label on focus; if there isn't one, they read nothing useful.
The fix is straightforward. Use <label htmlFor> paired with a matching id, or wrap the input inside the label element. If your design genuinely doesn't have visible labels (it happens), you can use aria-label or aria-labelledby — but visible labels are always better for everyone.
// Wrong — placeholder only
<input type="email" placeholder="Email address" />
// Right — explicit label
<label htmlFor="email">Email address</label>
<input type="email" id="email" name="email" />
// Also fine — label wrapping input
<label>
Email address
<input type="email" name="email" />
</label>Worth noting: error messages need to be associated with their inputs too. Use aria-describedby pointing at the error element's id. Otherwise a screen reader announces the validation error separately from the field it belongs to, and users can't tell what went wrong where.
Pattern 3: Aria Misuse — The Attribute Graveyard
ARIA attributes are powerful and widely misunderstood. Developers add them thinking more ARIA equals more accessible, but aria-label on a <div> that isn't interactive does nothing. aria-hidden="true" on an element that contains focusable children is actively harmful — it hides the container from the accessibility tree while focus can still land inside it.
The first rule of ARIA is: don't use ARIA if a native HTML element already conveys the same semantics. <nav> beats <div role="navigation">. <button> beats <div role="button" tabIndex={0}>. Always.
In practice, the most common bad ARIA pattern in React is this: a custom modal with aria-modal="true" but no focus trap, and no aria-labelledby connecting the dialog to its heading. The screen reader announces a modal opened, then the user Tab-keys into content behind it. That's a disaster.
// Custom dialog — the minimum viable accessible version
function Modal({ title, children, onClose }) {
const titleId = useId();
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<h2 id={titleId}>{title}</h2>
{children}
<button type="button" onClick={onClose}>
Close
</button>
</div>
);
}You still need a focus trap. Libraries like focus-trap-react handle that for you and are worth the 3 KB.
Pattern 4: Focus Management After Dynamic Content Changes
React renders are async from the DOM's perspective. You update state, a new panel appears, and focus stays exactly where it was — often on a button that triggered content which no longer exists, or somewhere completely unrelated to what just changed.
After a route change, focus should move to the new page's main heading or a skip-link target. After a modal opens, focus should go inside it. After it closes, focus returns to the trigger that opened it. None of this happens automatically. You have to wire it up with refs and useEffect.
function Modal({ isOpen, onClose, triggerRef }) {
const closeButtonRef = useRef(null);
useEffect(() => {
if (isOpen) {
// Move focus into modal when it opens
closeButtonRef.current?.focus();
}
}, [isOpen]);
const handleClose = () => {
onClose();
// Return focus to the trigger
triggerRef.current?.focus();
};
if (!isOpen) return null;
return (
<div role="dialog" aria-modal="true">
<button ref={closeButtonRef} onClick={handleClose}>
Close
</button>
</div>
);
}That said, live regions are a different case. If you're announcing status messages — "Item added to cart", "Form saved" — use aria-live="polite" on a visually hidden container and inject the message there. Don't move focus just to announce something.
Pattern 5: Colour Contrast and Motion You Haven't Tested
WCAG 2.1 AA requires a 4.5:1 contrast ratio for normal text and 3:1 for large text (18px+ or 14px+ bold). Most design systems get close on the happy path but fail on placeholder text, disabled states, and text over semi-transparent backgrounds. The glassmorphism aesthetic — which you might be using if you've been through Empire UI's glassmorphism components — is especially prone to this because the background shifts depending on what's underneath.
Run your UI through the axe DevTools browser extension. It'll catch contrast failures automatically. But also do a manual pass at different zoom levels — 200% zoom is a WCAG criterion, and most developers never test it.
Look, motion is a separate category that nearly everyone ignores. If your UI has animations — transitions, parallax, auto-playing carousels — you need to respect prefers-reduced-motion. One media query. That's it.
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}Pattern 6: Icon Buttons With No Accessible Name
An icon button — close, search, menu, delete — with no text label is one of the most common a11y failures in modern UIs. Screen readers announce it as "button" with no description. Is it the close button? The delete button? The user has no idea.
Three valid approaches: visible text alongside the icon, aria-label on the button, or a visually hidden span using the classic .sr-only pattern. Pick the one that fits your design. If you're unsure which components in your stack have this problem, browse the components in Empire UI — every interactive element ships with accessible names baked in.
// Approach 1 — aria-label
<button type="button" aria-label="Close dialog">
<XIcon aria-hidden="true" />
</button>
// Approach 2 — visually hidden text
<button type="button">
<XIcon aria-hidden="true" />
<span className="sr-only">Close dialog</span>
</button>Quick aside: always add aria-hidden="true" to decorative icons. Otherwise the screen reader reads SVG element names or path data, which is gibberish to users.
Testing Your a11y Work Without Going Insane
Automated tools catch maybe 30–40% of real-world accessibility issues. The rest requires manual testing. That doesn't mean you need to become an assistive tech expert overnight — it means spending 10 minutes with your keyboard, Tab-navigating every interaction you ship.
Your testing stack should include: axe DevTools (browser extension, free tier is good), eslint-plugin-jsx-a11y in your lint pipeline, and occasional real screen reader testing with NVDA on Windows or VoiceOver on macOS. If you want to go deeper, the Empire UI MCP page documents how you can integrate accessibility checks into your AI-assisted workflow.
Can you guarantee zero a11y issues? No. But shipping with aria-label on your icon buttons, <label> on every form input, visible focus indicators, and focus management in your modals puts you ahead of probably 80% of React apps in production right now. Start there. Iterate.
FAQ
Install the axe DevTools browser extension and run it on your key pages — it'll surface the most common failures in seconds. Follow that with a keyboard-only navigation pass through every interactive flow.
Not automatically. Component libraries give you accessible building blocks, but you still have to wire up focus management, ARIA relationships, and live regions correctly in your own code.
It works, but visible text labels are almost always better because they help all users, not just screen reader users. Use aria-label when a visible label genuinely isn't possible in the design.
Yes, at least occasionally. Automated tools miss interaction patterns — like whether focus trap in a modal actually works — that only surface when you Tab through with NVDA or VoiceOver running.