Modern HTML5 in Production · Semantics and Accessibility

ARIA Done Right (and Wrong)

Why adding ARIA made a menu less accessible, and the rules that keep ARIA from hurting: native first, never hide focusable content, and keep states in sync.

Scenario

Example app: after an accessibility complaint, a developer adds ARIA everywhere: role="button" on links used as buttons, aria-hidden="true" on a closed off-canvas menu that still contains focusable links, and aria-expanded="true" hard-coded on a toggle.

What you see in production

  • Keyboard focus disappears into the hidden menu: users tab "into nothing" and screen readers say nothing.
  • The toggle always announces "expanded", even when the menu is closed.
  • An automated audit reports more errors than before the ARIA was added.

The naive code

<a href="#" role="button" onclick="openMenu()">Menu</a>
<nav aria-hidden="true" class="off-canvas">
  <a href="/shop">Shop</a> <a href="/account">Account</a>
</nav>
<button aria-expanded="true" onclick="toggle()">Filters</button>

Reproduce it

The simulation runs a few common ARIA rules (the kind axe and Lighthouse check) over the naive and fixed markup, described as data.

Linting ARIA usageTry it yourself
function lint(elements) {
  const problems = [];
  for (const el of elements) {
    if (el.role === "button" && el.tag === "a") problems.push(`${el.name}: link given role=button - use a <button>`);
    if (el.ariaHidden && el.containsFocusable) problems.push(`${el.name}: aria-hidden but contains focusable elements`);
    if (el.ariaExpandedStatic) problems.push(`${el.name}: aria-expanded never changes - keep state in sync`);
    if (el.role && el.role === el.implicitRole) problems.push(`${el.name}: redundant role="${el.role}"`);
  }
  return problems;
}

const naive = [
  { name: "Menu toggle", tag: "a", role: "button", implicitRole: "link" },
  { name: "Off-canvas nav", tag: "nav", ariaHidden: true, containsFocusable: true, implicitRole: "navigation" },
  { name: "Filters toggle", tag: "button", ariaExpandedStatic: true, implicitRole: "button" },
  { name: "Save", tag: "button", role: "button", implicitRole: "button" },
];
const fixed = [
  { name: "Menu toggle", tag: "button", implicitRole: "button" },
  { name: "Off-canvas nav", tag: "nav", inert: true, implicitRole: "navigation" },   // hidden + inert when closed
  { name: "Filters toggle", tag: "button", implicitRole: "button" },               // aria-expanded updated on click
  { name: "Save", tag: "button", implicitRole: "button" },
];

for (const [label, markup] of [["naive", naive], ["fixed", fixed]]) {
  const problems = lint(markup);
  console.log(`${label}: ${problems.length} problem(s)`);
  for (const p of problems) console.log(`  - ${p}`);
}

Output

naive: 4 problem(s)
  - Menu toggle: link given role=button - use a <button>
  - Off-canvas nav: aria-hidden but contains focusable elements
  - Filters toggle: aria-expanded never changes - keep state in sync
  - Save: redundant role="button"
fixed: 0 problem(s)

Root cause

ARIA only changes what assistive technology is told; it adds no behaviour. Wrong roles promise behaviour that isn't there, aria-hidden hides content from screen readers but not from the keyboard, and static states contradict reality.

The fix

Follow the rules from the ARIA Authoring Practices Guide:

  1. Use native HTML first (<button>, <a href>, <details>, <dialog>, form controls).
  2. Don't change native semantics unless you must, and never add redundant roles.
  3. Everything interactive must work with the keyboard.
  4. Never use aria-hidden="true" on focusable elements or their containers. To hide a closed panel completely, use the hidden attribute or display: none, or make it inert.
  5. Keep states in sync: update aria-expanded, aria-selected, aria-checked when they change.

Trade-offs

  • Complex widgets (comboboxes, tree views, tabs) genuinely need ARIA; follow the APG patterns exactly and test with screen readers.
  • No ARIA is better than bad ARIA, but missing semantics on custom widgets is also a failure; prefer native elements to avoid the dilemma.

Detect it in production

  • Run axe or Lighthouse in CI on key pages.
  • Manual screen-reader checks for every custom widget, especially after refactors.

Key points

  • ARIA changes announcements, not behaviour.
  • Native elements first; no redundant roles; never aria-hidden focusable content.
  • Keep ARIA states updated, and follow APG patterns for custom widgets.

Practice questions

What is the first rule of ARIA?

Answer

If a native HTML element or attribute provides the semantics and behaviour you need, use it instead of adding ARIA to another element.

Why is aria-hidden on a container with focusable links a problem?

Answer

aria-hidden removes the content from the accessibility tree but not from keyboard focus, so users can tab into elements that screen readers don't announce. Hide it completely (hidden, display:none) or make it inert.

Does adding role="button" to a div make it a working button?

Answer

No. It only changes the announcement. You must also make it focusable (tabindex="0") and handle Enter and Space, which a native button does automatically.

Open in the app to mark lessons complete, take notes and track your progress.