ARIA — Accessible Rich Internet Applications — is a set of attributes that tell
assistive technology what a custom control is (its role), what state it’s in
(expanded, pressed, selected), and what it’s called. It’s indispensable for UI that
HTML has no element for: tabs, trees, comboboxes, menus. But ARIA is a promise, not a
behaviour. role="button" makes a screen reader say “button”, yet it adds
no click handler, no focusability, and no Space-or-Enter activation — you have to
supply all of that yourself, and most teams forget at least one piece.
That’s why ARIA’s own authoring practices put one rule first: don’t use ARIA
if a native HTML element with the role and behaviour you need already exists.
A real <button> is focusable, operable by keyboard, and announced
correctly with zero extra code. This lesson works through the four ARIA mistakes that
cause the most damage in audits — each one either replaces something native that
worked, or makes a promise to assistive technology that the markup doesn’t keep.
What you’ll learn
Why the first rule of ARIA is to reach for native HTML first; how
to give a genuinely custom widget the correct role and keep its state
attributes (aria-pressed, aria-expanded) in sync; how to
make every aria-labelledby, aria-controls, and
role reference something that actually exists; and why
aria-hidden="true" must never sit over content that is still
focusable.
Standards this lesson maps to
Standard
Criterion
Level
What it requires
WCAG 2.2
4.1.2 Name, Role, Value
A
Every interactive control exposes a correct role, a name, and any state or value — and updates them as the state changes.
WCAG 2.2
1.3.1 Info and Relationships
A
Relationships conveyed through ARIA references (labels, controls, owned elements) are present and valid in the markup.
WCAG 2.2
4.1.3 Status Messages
AA
State and status changes in custom widgets are exposed to assistive technology without moving focus.
EN 301 549
9.4.1.2 / 11.x (incorporates WCAG)
—
European harmonised standard; references the WCAG A/AA set including name, role, and value.
Section 508
502.3 / 504 (incorporates WCAG A & AA)
—
US federal ICT must meet WCAG 2.0 Level A and AA, including correct roles and states for controls.
ADA Title II
WCAG 2.1 AA (DOJ rule)
AA
US state/local government web content must conform to WCAG 2.1 AA.
The four problems we’ll fix
Each card below isolates one common ARIA defect. For every issue you get a
plain-language statement of the problem, a Bad example (shown as
escaped, non-running code so it can’t harm this page), a Good
example, the copyable Code, and an ordered fix.
ARIA bolted onto a <div> instead of native HTML
WCAG 2.2 · 4.1.2AEN 301 549Section 508
A <div role="button" tabindex="0">
looks like a button and now announces as one, but the role is only half
the job. ARIA adds no behaviour: the div isn’t activated by Space or Enter the way
a real button is, it isn’t a form submit, it gets no :disabled or
focus-ring defaults, and you have to hand-write key handlers that you’ll inevitably
get subtly wrong. Every one of those is free and correct with a native
<button>. This is the first rule of ARIA: if a native
element with the role and behaviour you need exists, don’t recreate it with
ARIA.
Bad
A generic element is dressed up as a button with a role and a tabindex. There’s
no keyboard activation, so it works with the mouse but not reliably with the
keyboard — and it reimplements something the platform already ships.
bad-div-button.html
<div role="button" tabindex="0" onclick="save()">Save</div>
<!-- No Space/Enter handling, no :disabled, no form submit. -->
Good
A native <button> is focusable, operable with Space and
Enter, and announced as “Save, button” — with no role, no tabindex, and no key
handlers to maintain.
The rule extends to every control with a native equivalent. Reach for the HTML
element first; only build with ARIA when nothing native expresses the pattern
(a tab list, a tree, a custom combobox).
native-equivalents.html
<button>…</button> <!-- not <div role="button"> -->
<a href="/page">…</a> <!-- not <span role="link"> -->
<input type="checkbox"> <!-- not <div role="checkbox"> -->
<select>…</select> <!-- not a div role="listbox" -->
<details><summary>…</summary></details> <!-- native disclosure -->
How to fix
Ask first whether a native element does the job — button,
a, input, select,
details. If so, use it and delete the ARIA.
Swap <div role="button"> for <button>:
you instantly get focusability, Space/Enter activation, and the right role.
Set type="button" on buttons that aren’t submitting a form, so
they don’t accidentally submit.
Reserve custom ARIA widgets for patterns HTML can’t express, and follow the
ARIA Authoring Practices keyboard model when you do.
Custom widget missing its role or state
WCAG 2.2 · 4.1.2A4.1.3AAEN 301 549
Name, Role, Value means a control must also
expose its current state. A mute toggle that flips colour on click
but never sets aria-pressed is announced the same whether it’s on or
off — the user can’t tell what state it’s in. An accordion header with no
aria-expanded never says whether its panel is open or closed. The state
attribute isn’t set-once: it must be kept in sync, updated in the same
handler that changes the visual state, so the accessibility tree always matches what
is on screen (4.1.2, 4.1.3).
Bad
The toggle has a role but no state, and the accordion trigger has neither a
button role nor aria-expanded. A screen reader user can’t hear
whether either one is currently on or open.
bad-no-state.html
<!-- Toggle: role but no aria-pressed -->
<button type="button" class="toggle">Mute</button>
<!-- Accordion: no button role, no aria-expanded -->
<div class="acc-header" onclick="toggle()">Shipping</div>
<div class="acc-panel">…</div>
Good
The toggle exposes aria-pressed and the accordion trigger is a
real <button> with aria-expanded. Each is
announced as “… toggle button, pressed” or “Shipping, button, collapsed”.
good-state.html
<!-- Toggle button: state is exposed -->
<button type="button" class="toggle" aria-pressed="false">Mute</button>
<!-- Accordion trigger: real button + expanded state -->
<button type="button" class="acc-header"
aria-expanded="false" aria-controls="acc-ship">Shipping</button>
<div id="acc-ship" class="acc-panel" hidden>…</div>
Code
The attribute is worthless if it goes stale. Flip it in the same handler that
changes the visual state, reading and writing the boolean so the two never
drift apart.
keep-state-in-sync.js
btn.addEventListener('click', () => {
const pressed = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', String(!pressed));
});
trigger.addEventListener('click', () => {
const open = trigger.getAttribute('aria-expanded') === 'true';
trigger.setAttribute('aria-expanded', String(!open));
panel.hidden = open; // visual and ARIA state move together
});
How to fix
Give the widget the role its pattern requires — or use a native element
that already has it, like <button> for a trigger.
Add the matching state attribute: aria-pressed for a toggle,
aria-expanded for a disclosure, aria-selected for a
tab or option, aria-checked for a custom checkbox.
Set its initial value in the markup, then update it in the same handler that
changes the appearance so they never fall out of sync.
Verify in the accessibility tree that toggling the control changes the
announced state (“pressed”, “expanded”) and not just the colour.
Broken ARIA references and invalid roles
WCAG 2.2 · 4.1.2A1.3.1AEN 301 549
ID-reference attributes like
aria-labelledby, aria-describedby, and
aria-controls only work if the id they name exists in the same
document. Point aria-labelledby at an id that was renamed, never
rendered, or lives in another component, and the relationship silently evaporates —
the control ends up with no accessible name at all. The same goes for
role: a typo or an invented value such as
role="buton" is not a real ARIA role, so the browser ignores it and
the element falls back to its generic meaning (4.1.2, 1.3.1).
Bad
The label reference points at an id that doesn’t exist, the
aria-controls names a panel that was renamed, and the role is
misspelled — so none of them do anything.
bad-references.html
<h2 id="dialog-heading">Edit profile</h2>
<!-- labelledby points at "dialog-title", which does not exist -->
<div role="buton" aria-labelledby="dialog-title">…</div>
<button aria-controls="menu-1">Menu</button>
<ul id="nav-menu">…</ul> <!-- no element with id="menu-1" -->
Good
Every reference resolves to a real id and the role is spelled correctly, so the
dialog is named “Edit profile” and the button is wired to the menu it actually
controls.
Catch these before they ship. Browser devtools, axe-core, and the HTML
validator all flag dangling id references and unknown roles — and a one-line
check can list every broken pointer on a page.
find-dangling-refs.js
// Log any aria-labelledby/controls/describedby that points at a missing id
['aria-labelledby', 'aria-controls', 'aria-describedby'].forEach(attr => {
document.querySelectorAll('[' + attr + ']').forEach(el => {
el.getAttribute(attr).split(/\s+/).forEach(id => {
if (id && !document.getElementById(id)) {
console.warn(attr + ' references missing id:', id, el);
}
});
});
});
How to fix
Make sure every id named in aria-labelledby,
aria-describedby, and aria-controls exists in the
same document and is unique.
When you rename or remove an element, update or delete the references that
pointed at it — broken pointers fail silently.
Use only valid ARIA roles from the spec; a misspelled or invented
role is ignored and leaves the element generic.
Run axe-core or the HTML validator in CI so dangling references and unknown
roles are caught automatically.
aria-hidden="true" over focusable content
WCAG 2.2 · 4.1.2AEN 301 549Section 508
aria-hidden="true" removes an element and
its descendants from the accessibility tree, but it does not remove them
from the tab order. Put it over a region that still contains a link, button, or
input and you create a trap: a keyboard user Tabs into a control the screen reader
refuses to announce, so focus appears to land on “nothing”. It’s a common side
effect of hiding an off-screen menu or an open-modal’s background without also
taking those controls out of the tab order. The visual state and the accessibility
state must agree (4.1.2).
Bad
The drawer is hidden from assistive technology but its link is still tabbable.
A keyboard user reaches a focused control that the screen reader announces as
nothing at all.
bad-aria-hidden.html
<!-- Hidden from AT, but the link is still in the tab order -->
<div class="drawer" aria-hidden="true">
<a href="/settings">Settings</a>
<button type="button">Sign out</button>
</div>
Good
Don’t hide focusable content from assistive technology. Use the
inert attribute, which removes the subtree from both the
accessibility tree and the tab order, so the two states match.
good-inert.html
<!-- inert removes it from AT and from the tab order together -->
<div class="drawer" inert>
<a href="/settings">Settings</a>
<button type="button">Sign out</button>
</div>
<!-- When open, remove inert. Or use the dialog element / hidden. -->
Code
If you can’t use inert, pair aria-hidden with
something that also drops the controls from the tab order — display:none /
hidden, or setting tabindex="-1" on each focusable
child while hidden.
hide-completely.html
<!-- hidden / display:none removes it from view, AT, and tab order -->
<div class="drawer" hidden>…</div>
<!-- Never combine these two: visible but unannounced -->
<!-- <div aria-hidden="true"><button>Still tabbable</button></div> -->
How to fix
Never put aria-hidden="true" on an element that contains a
focusable control unless that control is also removed from the tab order.
Prefer inert on the hidden subtree — it takes the content out of
both the accessibility tree and the tab order at once.
If a region is fully hidden, use hidden or
display:none instead of aria-hidden; that removes it
from view, assistive tech, and focus together.
Tab through the page: if focus ever lands somewhere the screen reader says
nothing, you have visible-but-hidden focusable content to fix.
Recap
Use native HTML before ARIA. A real <button> beats a
<div role="button"> on focus, keyboard, and announcement, for
free (4.1.2).
If you must build a custom widget, give it the right role and keep
its state attribute — aria-pressed, aria-expanded,
aria-selected — in sync on every change (4.1.2).
Make every ARIA reference resolve: aria-labelledby and
aria-controls must point at ids that exist, and every
role must be a real ARIA role (4.1.2, 1.3.1).
Never put aria-hidden="true" over focusable content. Use
inert or remove the elements from the tab order so the visual and
accessibility states match (4.1.2).
The same fixes satisfy WCAG, EN 301 549, Section 508, and ADA
Title II at once — prefer native elements and keep every ARIA promise true.