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 First Rule of ARIA: Do Not Use ARIA
The W3C publishes five rules for using ARIA, and the first one is deliberately blunt. If a native HTML element or attribute already has the semantics and behaviour you need, use it instead of repurposing a generic element and bolting a role on top.
Native elements ship with three things you would otherwise reimplement by hand: an implicit role, keyboard behaviour, and state that the browser updates for you. A <button> is focusable, activates on both Enter and Space, exposes a disabled state to the platform, and still renders as a button in Windows High Contrast Mode. Adding role="button" to a <div> gives you the role and nothing else.
That gap is where most of the extra errors come from. A developer adds the role, the accessibility panel shows the right name, and nobody notices that a keyboard user cannot activate the control at all.
// ❌ Wrong: a div pretending to be a button
<div role="button" onClick={submit}>Submit</div>
// ⚠️ Better, but you now maintain browser behaviour by hand
<div
role="button"
tabIndex={0}
onClick={submit}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
submit();
}
}}
>
Submit
</div>
// ✅ Correct: the platform already solved this
<button type="button" onClick={submit}>Submit</button>The second version is not wrong, but it is now your responsibility forever. You have to keep the key handling, the focus ring, the disabled state, the form association, and the high contrast rendering in sync with whatever the platform does next.
Most ARIA roles developers reach for in React have a native equivalent that removes the maintenance entirely:
| What developers write | Native replacement | What the browser gives you for free |
|---|---|---|
| `role="button"` on a div | `<button type="button">` | Focus, Enter and Space activation, disabled state |
| `role="checkbox"` with `aria-checked` | `<input type="checkbox">` | Space toggle, form submission, indeterminate state |
| `role="navigation"` on a div | `<nav>` | A landmark with no extra attributes at all |
| `role="progressbar"` with `aria-valuenow` | `<progress>` | Value announcement plus indeterminate rendering |
| `aria-expanded` plus a custom panel | `<details>` and `<summary>` | Keyboard toggle and find-in-page expansion |
| A hand-rolled `role="dialog"` | `<dialog>` with `showModal()` | Top layer, inert background, Escape to close |
None of this makes ARIA optional. Tab panels, comboboxes, menus, and toast notifications have no native equivalent in HTML, so ARIA is the only way to describe them. The rule is about ordering: reach for the native element first, and use ARIA only for the gap that remains.
How the Accessible Name Is Computed
Every element that a screen reader announces has an accessible name, and browsers compute it from several sources in a fixed order. The first source that produces a non-empty string wins, and everything below it is ignored.
Knowing the order explains most "why is my label not being read" bugs:
aria-labelledby, resolved by concatenating the text content of every referenced id in the order you list them.aria-label, used only whenaria-labelledbyis absent or resolves to an empty string.- The host language label:
<label htmlFor>for form controls,<caption>for tables,<legend>for fieldsets,altfor images. - The element's own text content, for roles that allow a name from content such as buttons, links, and headings.
- The
titleattribute, a last resort that also produces a mouse tooltip most users never see. placeholderon some inputs, a browser fallback you should never rely on deliberately.
// Announced as "Save draft": aria-labelledby beats everything below it
<button aria-labelledby="save-hint" aria-label="Save" title="Save as draft">
Submit
</button>
<span id="save-hint">Save draft</span>
// Announced as "Save": aria-label beats the visible text content
<button aria-label="Save">Submit</button>
// Announced as "Submit": no ARIA, so the text content becomes the name
<button>Submit</button>aria-describedby is a separate channel. It is announced after the name, usually following a short pause, and it is where hints, format requirements, and validation errors belong. Unlike aria-labelledby, it accepts multiple ids and reads all of them.
function EmailField({ error }: { error?: string }) {
const id = useId();
const hintId = `${id}-hint`;
const errorId = `${id}-error`;
return (
<div>
<label htmlFor={id}>Email address</label>
<input
id={id}
type="email"
// Both ids are read, in the order listed
aria-describedby={error ? `${hintId} ${errorId}` : hintId}
aria-invalid={error ? true : undefined}
/>
<p id={hintId}>We only use this to send receipts.</p>
{error && <p id={errorId}>{error}</p>}
</div>
);
}Note aria-invalid={error ? true : undefined} rather than aria-invalid={!!error}. React renders aria-invalid={false} as the literal string "false", which is valid but tells the screen reader the field was explicitly validated and passed. Passing undefined removes the attribute entirely, which is usually what you want before the user has submitted anything.
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%.
Why should I use useId instead of my own generated ids for aria-labelledby?
aria-labelledby and aria-describedby only work if the id in the attribute matches an id that actually exists in the DOM. Any id you generate yourself has to survive server rendering, hydration, and a component being mounted twice on the same page.
Math.random() and module-level counters fail all three. The server produces one value, the client produces another, and React reports a hydration mismatch. Worse, the attribute can end up pointing at an id that no longer exists, which silently leaves the element unnamed.
// Broken: server and client disagree, and the counter resets per bundle
const randomId = `field-${Math.random()}`;
let counter = 0;
const countedId = `field-${counter++}`;
// Correct: stable across server render, hydration, and every instance
const id = useId();
<h2 id={`${id}-title`}>Billing details</h2>
<section aria-labelledby={`${id}-title`}>...</section>Call useId() once per component and derive suffixed ids from it rather than calling it several times. One caveat: the generated value contains punctuation that is not valid in a CSS selector, so look elements up with document.getElementById(id) instead of querySelector('#' + id).
Where should focus go after a modal closes?
Back to the element that opened it. If you skip the restore, focus falls back to <body> and the next Tab press starts at the top of the page. A screen reader user loses their place entirely, which is the most common complaint about otherwise well-labelled dialogs.
const triggerRef = useRef<HTMLButtonElement>(null);
function closeModal() {
setOpen(false);
// Restore after React has committed the removal
requestAnimationFrame(() => triggerRef.current?.focus());
}
<button ref={triggerRef} onClick={() => setOpen(true)}>Edit profile</button>Guard the restore for the case where the trigger no longer exists, for example a delete button inside a row the modal just removed. When triggerRef.current is gone, move focus to the nearest stable element instead (the table heading, the list container, or the page <h1>).
The native <dialog> element handles this for you: showModal() records the previously focused element and close() returns focus to it. That is one more reason to prefer it over a div-based dialog.
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
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.
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.