Skip to main content

ARIA Mistakes Developers Make (and How to Fix Them)

Benjamin Morton

Start with an uncomfortable number. Home pages that use ARIA average 59.1 detected accessibility errors, against 42.0 for pages that don't: a 40.7% penalty (WebAIM Million, 2026). ARIA is a specification designed to make pages more accessible, and the pages using it are measurably worse.

The gap is also widening. In 2024 it was 34.2%, and ARIA adoption has climbed from 74.6% to 82.7% of home pages over the same period. The average page now carries more than 133 ARIA attributes (WebAIM Million, 2026).

WebAIM is careful here, and so should we be: this is correlation, not proven causation. Pages that reach for a lot of ARIA also tend to be complex, and complexity brings its own bugs. But there's a simpler explanation running underneath, and the W3C's own authoring guide states it in one line: "Unlike HTML input elements, ARIA roles do not cause browsers to provide keyboard behaviors or styling" (W3C ARIA Authoring Practices Guide, 2026).

ARIA is a promise, not an implementation. The eight ARIA mistakes below are all the same mistake wearing a different hat: someone used ARIA to describe behaviour they never built.

TL;DR: Pages using ARIA average 40.7% more errors than pages without it, a gap that widened from 34.2% in 2024 (WebAIM Million, 2026). ARIA roles don't give you keyboard behaviour, so a role="button" without a keydown handler is a lie. The nastiest bug is aria-hidden="true" on a focusable element, which hides it from screen readers but leaves it in the tab order. And eslint-plugin-jsx-a11y doesn't check for that one by default, in either its recommended or its strict config.

First, the Rule Everyone Gets Wrong

There are four rules of ARIA, not five. You'll see "the five rules of ARIA" all over the web, with a fifth rule about interactive elements needing accessible names. That fifth rule is not a W3C rule. It originates from a Deque blog post published in 2019 and presented there as Deque's own addition to the W3C's four. The advice it gives is perfectly good, but it carries no more authority than any other blog post.

Worth knowing too: the W3C's Using ARIA document, where the four rules live, became a Discontinued Draft on 24 February 2026 (W3C, 2026). The rules are kept for reference, but the live, maintained authority is now the ARIA Authoring Practices Guide. Send people there.

The first rule is still the one that matters most:

"If you can use a native HTML element or attribute with the semantics and behavior you require already built in, instead of re-purposing an element and adding an ARIA role, state or property to make it accessible, then do so." (W3C, Using ARIA)

And the APG's blunter version: "No ARIA is better than Bad ARIA" (W3C APG, 2026).

More ARIA, Wider Error Gap 2024 2026 74.6% 82.7% Home pages using ARIA 34.2% 40.7% Extra errors vs pages without ARIA WebAIM notes this is correlation, not proven causation: ARIA-heavy pages are also more complex. Source: WebAIM Million, 2024 and 2026 reports

1. Reaching for ARIA When HTML Already Does It

The classic. A <div role="button"> announces itself as a button and does nothing a button does.

// Broken: says "button", behaves like a div
<div role="button" onClick={submit}>Submit</div>

You've promised a button and shipped a div. It isn't focusable, so keyboard users never reach it. It doesn't fire on Enter or Space. It won't submit a form, can't be disabled, and gets no focus ring. To honour the promise you'd need to add all of that by hand:

// Works, but you've now rebuilt <button> badly
<div
  role="button"
  tabIndex={0}
  onClick={submit}
  onKeyDown={(e) => {
    if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); submit(); }
  }}
>
  Submit
</div>
// Just use the element
<button type="button" onClick={submit}>Submit</button>

role="button" now appears an average of 4.8 times per home page, up from 3.6 in 2025 (WebAIM Million, 2026). A real <button> never needs it.

2. aria-hidden="true" on a Focusable Element

This is the worst one on the list, and it's growing fastest. Home pages now average 23.3 aria-hidden="true" attributes, up 30% in a year and more than 250% since 2020 (WebAIM Million, 2026).

The mechanism is the thing to internalise: the accessibility tree and the tab order are two different structures, and aria-hidden only touches one of them. Hide a focusable element and a keyboard user still tabs to it. It just no longer announces anything. The W3C's fourth rule says it plainly:

"Do not use role="presentation" or aria-hidden="true" on a focusable element... Using either of these on a focusable element will result in some users focusing on 'nothing'." (W3C, Using ARIA)

The version that actually ships is the ancestor case, because aria-hidden inherits:

// Closed drawer: invisible to screen readers, still fully tabbable
<div className="drawer" aria-hidden={!isOpen}>
  <a href="/pricing">Pricing</a>
  <button onClick={close}>Close</button>
</div>

A screen reader user tabs into a closed drawer and hears silence, several times. That's the "focusing on nothing" the spec warns about. Every closed modal, off-screen nav, and inactive carousel slide is a candidate.

The fix is to remove it from the tab order too, not just from the tree. inert does both in one attribute and is now widely supported:

// inert removes it from the tab order AND the accessibility tree
<div className="drawer" inert={!isOpen}>
  <a href="/pricing">Pricing</a>
  <button onClick={close}>Close</button>
</div>

One React gotcha. inert only became a real boolean prop in React 19. On React 18 and below it's an unknown attribute, so inert={true} warns and renders nothing at all. If you're on 18, pass the empty string instead: inert={!isOpen ? '' : undefined}.

display: none and visibility: hidden also do both, and are fine when you aren't animating. What is never sufficient, on its own, for anything focusable, is aria-hidden.

Citation capsule: aria-hidden="true" removes an element from the accessibility tree but not from the keyboard tab order, so a screen reader user can focus an element that announces nothing. The W3C's fourth rule of ARIA states that using it on a focusable element "will result in some users focusing on 'nothing'" (W3C, Using ARIA). It inherits to descendants, so aria-hidden on a closed drawer silently traps every link and button inside it. Home pages average 23.3 aria-hidden attributes, up over 250% since 2020 (WebAIM Million, 2026).

3. role="menu" for Site Navigation

Nearly one in four ARIA menus is broken. 5.7% of home pages use role="menu", and 22% of those introduce accessibility barriers through missing menu markup and interactions (WebAIM Million, 2026).

Let's be accurate about why, because a lot of posts overstate this. The APG does not forbid using menubar for navigation; it ships a navigation menubar example. What it does is hand you a bill, and the bill is the keyboard contract. The APG's menubar pattern expects Tab to move focus out of the menu rather than between its items, arrow keys to move between items and open submenus, Escape to close and restore focus, and Home and End to jump to the ends.

Now the part that matters, and it's the same principle as everywhere else in this post. Adding role="menu" does not give you any of that. It doesn't change the tab order, and it doesn't bind a single key. Your <a href> elements stay natively focusable, so Tab still walks through them exactly as before. The roving tabindex="-1" that produces real menu behaviour is code you have to write.

What the role does change is what assistive technology expects. It announces the widget as an application menu, which cues screen reader users to switch modes and drive it with arrow keys. Those arrow keys then do nothing, because nobody implemented them. You've advertised a contract and shipped none of it, which is exactly how 22% of these end up broken.

Adrian Roselli, who has argued this case since 2017 and filed an issue against the APG's navigation menubar pattern (it was rejected), puts the alternative simply: "A properly-coded web page navigation menu needs no ARIA" (Roselli, updated 2025).

// You don't need a menu role. You need a nav and a list.
<nav aria-label="Main">
  <ul>
    <li><a href="/docs">Docs</a></li>
    <li><a href="/pricing" aria-current="page">Pricing</a></li>
  </ul>
</nav>

For a dropdown, use a disclosure (a <button aria-expanded> that shows a list), not a menu. You keep Tab, and you keep your sanity.

4. aria-label on a <div> or <span>

This one is normatively forbidden, and most developers don't know it. The W3C's ARIA in HTML specification, which is a Recommendation rather than a note, says:

"Authors MUST NOT specify aria-label or aria-labelledby on elements with implicit WAI-ARIA roles which cannot be named." (W3C, ARIA in HTML)

A bare <div> or <span> has the implicit role generic, which is one of the roles that cannot be named. So this does not do what you think:

// Prohibited. Behaviour varies by browser and screen reader.
<div aria-label="Latest news">{items}</div>

It isn't merely ignored, which would at least be predictable. Manuel Matuzović tested this across Safari, Chrome and Firefox against VoiceOver, TalkBack, JAWS, NVDA and Narrator in May 2026, and found the results vary dramatically: some combinations announce the label, some ignore it entirely and read only the content, and some announce both (Matuzović, 2026). Unreliable is worse than useless, because you can't test your way to confidence.

// Give it a role that can be named...
<div role="region" aria-label="Latest news">{items}</div>

// ...or better, use the element that already means this
<section aria-labelledby="news-heading">
  <h2 id="news-heading">Latest news</h2>
  {items}
</section>

Note the same rule bites on <p>, <span>, <strong>, <em>, <code> and a long list of others. If you're labelling something that isn't interactive and isn't a landmark, stop and check.

5. Roles That Fight the Element They're On

Redundant roles (<nav role="navigation">, <button role="button">) are just noise. The dangerous ones overwrite a native semantic:

// The h1 is no longer a heading. It has vanished from the heading list.
<h1 role="button" onClick={toggle}>Settings</h1>

Screen reader users navigate by headings: 71.6% of them jump through a long page that way (WebAIM Screen Reader Survey #10, 2024). Overwrite the heading role and that <h1> disappears from the heading list entirely, taking the page's primary navigation structure with it for the 71.6%.

This is the second rule of ARIA: "Do not change native semantics, unless you really have to." Put the button inside the heading instead:

<h1>
  <button type="button" onClick={toggle}>Settings</button>
</h1>

6. Live Regions That Mount With Their Content

A live region only announces changes, which means the region has to exist in the accessibility tree before the content arrives. MDN is explicit: add the attribute "before the changes occur", and "start with an empty live region, then, in a separate step, change the content inside the region" (MDN, 2026).

Which makes this extremely common React pattern unreliable:

// The region and its content mount in the same commit.
// There is no "change" to observe, so it may never announce.
{error && <div role="alert">{error}</div>}

Keep the region mounted and change only what's inside it:

// Always in the DOM. Only the text changes.
<div role="status" className="sr-only">
  {error}
</div>

Note that role="status" already implies aria-live="polite", so you don't need both. The same goes for role="alert", which implies assertive: adding aria-live="assertive" alongside it causes double-speaking in VoiceOver on iOS (MDN, 2026).

One judgement call to make deliberately. status is polite and waits for a pause; alert is assertive and interrupts. If you genuinely want to interrupt, keep a permanently mounted role="alert" and change its text, rather than mounting the alert node itself. Use it sparingly, though, because interrupting whatever the user is currently hearing is, as MDN puts it, "extremely annoying and disruptive".

7. aria-expanded on the Wrong Element, or Out of Sync

aria-expanded belongs on the control that does the toggling, not on the panel it toggles. The APG's disclosure pattern is precise: "The element that shows and hides the content has role button", and that element carries aria-expanded, true when the content is visible and false when it isn't (W3C APG, 2026).

// Wrong: state is on the panel
<button onClick={() => setOpen(!open)}>Details</button>
<div aria-expanded={open}>{content}</div>
// Right: state is on the button, and both are driven by one source of truth
<button type="button" aria-expanded={open} onClick={() => setOpen(!open)}>
  Details
</button>
{open && <div>{content}</div>}

The subtler bug is a stale value, where aria-expanded is driven by one piece of state and the CSS by another. They drift, and now your markup is confidently telling screen reader users the opposite of what sighted users can see. Derive both from the same boolean. And note aria-controls is optional in this pattern, whatever you may have read.

8. aria-labelledby Pointing at Nothing

A broken IDREF fails quietly, and what it costs you depends on which attribute broke.

aria-describedby pointing at nothing simply loses the description. Your error message or hint is never announced, and nothing in the interface indicates that it's gone. The control still has its name, so a scan of the page looks healthy.

aria-labelledby pointing at nothing is more subtle than it's usually described. The name computation doesn't stop dead; it falls through to aria-label, and then to the element's own content. So <button aria-labelledby="missing">Save</button> is still announced as "Save". The failure only becomes total when the broken reference was the only source of a name, which is exactly the icon-only button case:

// The icon is decorative, so it contributes no name.
// If #tooltip-4 isn't rendered, this button has NO accessible name at all.
// The screen reader announces "button" and stops.
<button aria-labelledby="tooltip-4">
  <TrashIcon aria-hidden="true" />
</button>

The fix is to give it a name that cannot go missing:

// aria-label doesn't depend on another element existing.
<button aria-label="Delete" onClick={remove}>
  <TrashIcon aria-hidden="true" />
</button>

In React the usual causes are a hardcoded id on a component that renders more than once, so the IDs collide, or a target that's conditionally rendered and simply isn't in the DOM when the reference is evaluated. useId exists precisely for this:

function Field({ label, hint }) {
  const id = useId();
  return (
    <>
      <label htmlFor={`${id}-input`}>{label}</label>
      <input id={`${id}-input`} aria-describedby={hint ? `${id}-hint` : undefined} />
      {hint && <p id={`${id}-hint`}>{hint}</p>}
    </>
  );
}

Note the undefined when there's no hint. Pointing aria-describedby at an ID that isn't rendered is exactly the bug.

Your Linter Probably Isn't Checking the Worst One

Here's something you can verify in your own project in about thirty seconds. eslint-plugin-jsx-a11y does not check for aria-hidden on focusable elements by default. Not in recommended, and not in strict either.

We installed the current version (6.10.2) and read its exported configs. It ships 39 rules. 34 are in recommended and 33 are in strict. Two useful ones are in neither:

Rule

In recommended?

In strict?

jsx-a11y/no-aria-hidden-on-focusable

No

No

jsx-a11y/prefer-tag-over-role

No

No

jsx-a11y/no-redundant-roles

Yes

Yes

jsx-a11y/role-supports-aria-props

Yes

Yes

So mistake #2, the nastiest bug on this list, sails straight through a default Next.js lint setup. Turn both on by hand:

// eslint.config.js (neither of these is enabled by any preset)
export default [
  {
    rules: {
      'jsx-a11y/no-aria-hidden-on-focusable': 'error',
      'jsx-a11y/prefer-tag-over-role': 'error',
    },
  },
];

That's a two-line change that catches a serious, WCAG 4.1.2 Level A failure at author time, before it ever reaches a scanner.

Which ARIA Mistakes Automation Catches

Most of these are machine-detectable, which is the good news. Some are not, and knowing which is which tells you where to spend your manual testing time.

Mistake

axe-core

ESLint (jsx-a11y)

Needs a human

1. ARIA over native HTML

button-name only

prefer-tag-over-role (off by default)

Yes: focus and key handling

2. aria-hidden on focusable

aria-hidden-focus

no-aria-hidden-on-focusable (off by default)


3. role="menu" misuse

Structure only


Yes: arrow-key behaviour

4. aria-label on generic

aria-prohibited-attr

Nothing


5. Conflicting roles

aria-allowed-role (best-practice tag, often off in CI)

no-redundant-roles catches redundant only, never <h1 role="button">


6. Live region timing


Nothing

Yes: it's a runtime behaviour

7. Stale aria-expanded

Value is valid either way

Nothing

Yes: state correctness

8. Broken IDREFs

aria-valid-attr-value

Nothing


Two patterns fall out of that table. First, a scanner sees structure, never behaviour. It can confirm your role="menu" has the right children; it cannot tell you whether the down-arrow key does anything. Second, note how much column three is doing: the mistakes automation misses are all about whether the code does what it claims, which is the same theme as the whole post.

We wrote about the split between what tools catch and what people have to in automated vs manual accessibility testing, and the GitHub Actions guide covers wiring axe-core into CI.

When You Should Reach for ARIA

None of this is an argument against ARIA itself, only against using it as decoration. The W3C is clear that without ARIA, "certain functionality used on websites is not available to some users with disabilities, especially people who rely on screen readers and people who cannot use a mouse" (W3C WAI, 2026).

Reach for ARIA when HTML genuinely cannot express what you mean:

  • Live regions (aria-live, role="status"). HTML has no way to say "announce this when it changes".
  • aria-current for the active item in a nav or a paginated set.
  • aria-describedby to bind an error message or hint to the input it belongs to.
  • aria-expanded for disclosure state, which no HTML element expresses on its own.
  • Composite widgets HTML has no element for: tabs, comboboxes, tree views.
  • aria-label on a genuine <button> that contains only an icon.

Every item on that list adds semantics HTML can't carry. That's ARIA doing its job. The failures in this post are all the other case: using ARIA to claim behaviour, rather than to describe meaning.

Frequently Asked Questions

Does using ARIA make a website less accessible?

Not inherently, but the data is sobering. Pages using ARIA average 59.1 detected errors against 42.0 for pages without it, a 40.7% penalty (WebAIM Million, 2026). WebAIM notes this is correlation rather than proven causation, since ARIA-heavy pages are also more complex. Bad ARIA is worse than none.

What is the most serious ARIA mistake?

Putting aria-hidden="true" on a focusable element, or on an ancestor of one. It removes the element from the accessibility tree but not from the tab order, so a keyboard user focuses something that announces nothing. The W3C warns this results in "some users focusing on 'nothing'" (W3C). Use inert instead.

How many rules of ARIA are there?

Four, not five. The W3C's Using ARIA document defines four rules and became a Discontinued Draft in February 2026 (W3C). The widely-cited "fifth rule" about accessible names originates from a 2019 Deque blog post, not from the W3C. The maintained authority is now the ARIA Authoring Practices Guide.

Can I use aria-label on a div?

No. The W3C's ARIA in HTML Recommendation states authors "MUST NOT" put aria-label or aria-labelledby on elements whose implicit role cannot be named, and a bare div has the role generic (W3C). Testing across five screen readers in 2026 found behaviour varies wildly (Matuzović).

Does eslint-plugin-jsx-a11y catch these?

Some, but not the worst one. no-aria-hidden-on-focusable is in neither the recommended nor the strict config of version 6.10.2, so it's off unless you enable it by hand. Nothing in the plugin catches aria-label on generics, broken IDREFs, live-region timing, or stale aria-expanded.

The Bottom Line

ARIA-heavy pages carry 40.7% more errors than pages without it, and the gap is growing as adoption climbs past 82% (WebAIM Million, 2026). The specification isn't the problem. ARIA lets you say something is a button, a menu, or hidden without doing any of the work that would make it true, and browsers will not fill that gap for you.

So treat every ARIA attribute as a claim you have to back. If you write role="button", you owe it focus and keyboard activation. If you write role="menu", you owe it arrow keys. If you write aria-hidden, you owe it a matching removal from the tab order. And most of the time, the element you actually wanted already exists in HTML and does all of it for free.

Start by scanning what you've shipped. The common WCAG violations guide covers the highest-volume failures, web accessibility statistics has the wider data, and the React testing guide covers component-level setup.


Sources