ARIA & custom widgets

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.2 A EN 301 549 Section 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.

good-button.html
<button type="button" onclick="save()">Save</button>

Code

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

  1. Ask first whether a native element does the job — button, a, input, select, details. If so, use it and delete the ARIA.
  2. Swap <div role="button"> for <button>: you instantly get focusability, Space/Enter activation, and the right role.
  3. Set type="button" on buttons that aren’t submitting a form, so they don’t accidentally submit.
  4. 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.2 A 4.1.3 AA EN 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

  1. Give the widget the role its pattern requires — or use a native element that already has it, like <button> for a trigger.
  2. 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.
  3. 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.
  4. 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.2 A 1.3.1 A EN 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.

good-references.html
<h2 id="dialog-heading">Edit profile</h2>
<div role="dialog" aria-labelledby="dialog-heading">…</div>

<button aria-controls="nav-menu" aria-expanded="false">Menu</button>
<ul id="nav-menu">…</ul>

Code

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

  1. Make sure every id named in aria-labelledby, aria-describedby, and aria-controls exists in the same document and is unique.
  2. When you rename or remove an element, update or delete the references that pointed at it — broken pointers fail silently.
  3. Use only valid ARIA roles from the spec; a misspelled or invented role is ignored and leaves the element generic.
  4. 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.2 A EN 301 549 Section 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

  1. Never put aria-hidden="true" on an element that contains a focusable control unless that control is also removed from the tab order.
  2. Prefer inert on the hidden subtree — it takes the content out of both the accessibility tree and the tab order at once.
  3. 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.
  4. 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.