ARIA in React: Stop Using aria-label Wrong
Pages using ARIA average 41% more accessibility errors. Learn the correct ARIA patterns for React: icon buttons, modals, toasts, spinners, and tab panels.
On this page
WebAIM's annual accessibility analysis found that pages using ARIA attributes average 41% more accessibility errors than pages without ARIA at all. That's not a reason to avoid ARIA — it's a sign that most developers reach for it reflexively and apply it wrong.
This post covers the five React component patterns where developers consistently get ARIA wrong, and what correct looks like in each case. Each pattern shows the wrong implementation alongside the correct one.
The Rules You Need to Know First
ARIA exists to bridge gaps when native HTML can't convey enough meaning to assistive technologies. The W3C's first rule of ARIA: if a native HTML element can do the job, use it. A <button> is already accessible — adding role="button" to a <div> is extra work that creates new failure modes.
When you do need ARIA, the most important choice is which labelling attribute to use:
| aria-label | aria-labelledby | |
|---|---|---|
| Value type | String you write directly | ID reference to another element |
| When to use | No visible text exists that names the element | Visible text already names the element |
| Translation-safe? | No — skipped by browser translators | Yes — translated visible text = translated accessible name |
| Canonical use case | Icon-only button with no label | Modal dialog pointing to its visible h2 title |
The 5 Patterns
- 1
Icon Button with No Visible Text
This is one of the few legitimate uses for
aria-label— when there is genuinely no visible text and adding a label isn't feasible. Two details matter: thearia-labelon the button andaria-hidden="true"on the icon to prevent the screen reader from also describing the SVG.tsx// ❌ Wrong — no accessible name, screen reader announces nothing useful <button onClick={onClose}> <XIcon /> </button> // ✅ Correct — aria-label gives it an accessible name <button onClick={onClose} aria-label="Close dialog"> <XIcon aria-hidden="true" /> </button>aria-hidden="true"on the icon prevents the screen reader from announcing "Close dialog, svg" — redundant once the button has a name- Without
aria-hiddenon the icon, screen readers may announce both thearia-labeland the SVG title - If a visible tooltip exists, consider making it the accessible name via
aria-labelledbyinstead
- 2
Modal Dialog
A modal needs three things:
role="dialog", a label connecting it to its visible title, and focus management. Most React implementations get one or two and miss the third.tsx — ❌ Wrong — missing label and focus management<div className="modal"> <h2>Confirm Delete</h2> <p>Are you sure?</p> <button onClick={onClose}>Cancel</button> </div>tsx — ✅ Correct — role, aria-labelledby, focus managementfunction Modal({ isOpen, title, children, onClose }) { const titleId = useId(); const modalRef = useRef<HTMLDivElement>(null); useEffect(() => { if (isOpen) modalRef.current?.focus(); }, [isOpen]); if (!isOpen) return null; return ( <div role="dialog" aria-modal="true" aria-labelledby={titleId} // points to visible title — translation-safe tabIndex={-1} ref={modalRef} > <h2 id={titleId}>{title}</h2> {children} <button onClick={onClose}>Close</button> </div> ); }aria-modal="true"tells screen readers to treat content outside the dialog as inert — without it, some screen readers let users navigate behind the modaltabIndex={-1}allows thedivto receive programmatic focus fromuseEffectwithout entering the tab order- Focus must return to the triggering element when the modal closes — add
triggerRef.current?.focus()in the close handler
- 3
Toast Notifications with aria-live
Toast notifications are the most common case where developers forget ARIA entirely, leaving screen reader users unaware a message appeared.
tsx — ❌ Wrong — dynamically injecting the live region// Injecting a live region at toast time — screen readers won't announce it {showToast && ( <div aria-live="polite">{message}</div> )}tsx — ✅ Correct — persistent live region, always in the DOMfunction ToastContainer({ message }: { message: string | null }) { return ( // Always rendered — screen readers observe it from page load // aria-atomic="true" announces the whole region, not just changed text <div aria-live="polite" aria-atomic="true" className="sr-only" > {message} </div> ); }- The container must always be in the DOM — screen readers only observe live regions that existed when the page loaded
aria-live="polite"waits for current speech to finish — use for success messages and status updatesaria-live="assertive"interrupts immediately — reserve for genuine errors requiring immediate attention
- 4
Loading Spinner
A spinning animation with no text tells a screen reader nothing.
role="status"is live region shorthand foraria-live="polite".tsx — ❌ Wrong — no accessible information<div className="spinner"> <svg className="animate-spin" /> </div>tsx — ✅ Correct — role, label, hidden iconfunction LoadingSpinner({ label = "Loading..." }: { label?: string }) { return ( <div role="status" aria-label={label}> <svg aria-hidden="true" className="animate-spin" /> <span className="sr-only">{label}</span> </div> ); }aria-hidden="true"prevents screen readers from attempting to describe the SVG animation- The
sr-onlyspan provides a fallback in browsers where live regionaria-labelsupport is inconsistent - When loading completes, ensure focus or a content update announces the result — the spinner disappearing is not announced
- 5
Tab Panel
Custom tab components are common in React and almost universally implemented without correct ARIA. The
aria-controls/aria-labelledbypairing creates a semantic relationship between each tab and its panel.tsx — ❌ Wrong — no ARIA roles or relationships<div className="tabs"> {tabs.map((tab, i) => ( <button key={tab.id} className={activeTab === i ? 'active' : ''} onClick={() => setActiveTab(i)} > {tab.label} </button> ))} <div>{tabs[activeTab].content}</div> </div>tsx — ✅ Correct — full ARIA tab patternfunction Tabs({ tabs }: { tabs: Tab[] }) { const [activeTab, setActiveTab] = useState(0); return ( <div> <div role="tablist" aria-label="Account settings"> {tabs.map((tab, i) => ( <button key={tab.id} role="tab" aria-selected={activeTab === i} // active state — not aria-pressed aria-controls={`panel-${tab.id}`} // links tab to its panel id={`tab-${tab.id}`} onClick={() => setActiveTab(i)} > {tab.label} </button> ))} </div> {tabs.map((tab, i) => ( <div key={tab.id} role="tabpanel" id={`panel-${tab.id}`} aria-labelledby={`tab-${tab.id}`} hidden={activeTab !== i} > {tab.content} </div> ))} </div> ); }aria-selectedcommunicates the active tab —aria-pressedis wrong here and confuses the tab patternhiddenis preferred over CSSdisplay: none— it removes inactive panels from the accessibility tree entirely- Arrow-key navigation between tabs is part of the ARIA pattern — a tab that only responds to click fails keyboard users
Accessibility Shipping Checklist
- Icon-only buttons have
aria-labeland SVG hasaria-hidden="true" - Modals have
role="dialog",aria-modal="true",aria-labelledbypointing to the visible title, and focus management - Toast/notification container is always in the DOM with
aria-live="polite"andaria-atomic="true" - Loading spinners have
role="status"andaria-hiddenon the SVG - Tab components use
role="tab",aria-selected,aria-controls, androle="tabpanel"witharia-labelledby - Ran axe DevTools — zero violations
- Tested keyboard navigation — every interactive element reachable without a mouse
- Tested with VoiceOver or NVDA — announcements make sense without visual context
Testing Tools
Automated tools catch 30–40% of issues. Manual screen reader testing catches the rest.
| Tool | What it catches | When to use |
|---|---|---|
| axe DevTools (browser extension) | Missing labels, ARIA misuse, contrast | During development, component-level |
| VoiceOver (Mac, Cmd+F5) | Announcement quality, focus flow, live regions | Before shipping any interactive component |
| NVDA (Windows, free) | Same as VoiceOver — different engine | Cross-browser/platform verification |
| @axe-core/react | Violations logged to console automatically | Development builds, CI accessibility checks |
Add @axe-core/react to your development build in 5 lines:
if (process.env.NODE_ENV !== 'production') {
const axe = await import('@axe-core/react');
const React = await import('react');
const ReactDOM = await import('react-dom');
axe.default(React.default, ReactDOM.default, 1000);
}Frequently Asked Questions
Is ARIA required to make a React app accessible?
- When ARIA is required:
role="dialog"for modals,aria-livefor dynamic announcements,role="tab"/role="tabpanel"for custom tab patterns — interactive patterns with no native HTML equivalent. - When ARIA is not required: styling hooks, layout wrappers, server-rendered static content, anything a native element already handles correctly.
- Most accessibility comes from correct semantic HTML. A
<button>is accessible without ARIA. A<label>withhtmlForalready associates with its input.
Does aria-label get translated by browser translation tools?
No. Chrome, Edge, and Firefox translation extensions translate visible DOM text but skip aria-label strings. A user who translates your English page hears English aria-label while reading translated visible content.
Fix: use aria-labelledby to reference visible text. When the visible text is translated, the accessible name is translated automatically.
What is the difference between aria-live="polite" and aria-live="assertive"?
| polite | assertive | |
|---|---|---|
| Timing | Announces after current speech finishes | Interrupts immediately |
| Use for | Success toasts, status messages, form feedback | Critical errors requiring immediate action |
| Default choice? | Yes — when in doubt, use polite | No — use sparingly |
How much of my accessibility do automated tools actually check?
Automated tools (axe, Lighthouse) catch roughly 30–40% of accessibility issues — primarily missing labels, contrast failures, and clear structural violations.
They cannot catch: whether announced content makes sense in context, whether focus management feels correct, whether live region timing is appropriate, or whether keyboard navigation follows expected patterns. Manual testing with VoiceOver or NVDA is the only way to catch the remaining 60–70%.
The 41% stat isn't an argument against ARIA — it's an argument against reflexive ARIA. Reach for native HTML first. When you do need ARIA, use aria-labelledby over aria-label whenever visible text already names the element.
Test with a screen reader before shipping. Axe catches structure violations; only VoiceOver or NVDA tells you whether the experience actually makes sense when you can't see the screen.
Related Articles
How to Use Environment Variables in Next.js (Without Leaking Them to the Browser)
Learn how to use .env files in Next.js correctly. Understand NEXT_PUBLIC_, avoid common mistakes, and set variables in Vercel and Cloudflare.
8 CSS :has() Patterns You'll Actually Use (2026)
CSS :has() is production-ready in every browser. Here are 8 real-world patterns — form states, sibling dimming, modal scroll-lock, and more.
Husky + Prettier + lint-staged Setup for Next.js
Set up Husky v9, Prettier, and lint-staged in your Next.js project. Step-by-step guide covering pre-commit hooks with the correct 2026 config.