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.
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:
- Use native HTML first (
<button>,<a href>,<details>,<dialog>, form controls). - Don't change native semantics unless you must, and never add redundant roles.
- Everything interactive must work with the keyboard.
- Never use
aria-hidden="true"on focusable elements or their containers. To hide a closed panel completely, use thehiddenattribute ordisplay: none, or make itinert. - Keep states in sync: update
aria-expanded,aria-selected,aria-checkedwhen 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-hiddenfocusable 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.