ARIA Basics: Roles, States and Properties

ARIA fills gaps that HTML cannot. It also breaks pages when used carelessly. The rules, the attributes worth knowing, and the ones to leave alone.

Concept

ARIA - Accessible Rich Internet Applications - is a set of attributes that describe roles, states and relationships to assistive technology when HTML alone cannot.

It has one property worth understanding above all others: ARIA changes what is announced. It never changes behaviour. Adding role="button" to a div makes a screen reader say button. It does not make the element focusable, does not make Enter or Space work, and does not give it a disabled state.

The five rules

  1. Do not use ARIA if a native element exists. button beats div role="button" every time.
  2. Do not change native semantics unless you must. <h2 role="tab"> removes the heading from the outline.
  3. Every interactive ARIA control must be keyboard operable. Which you have to implement yourself.
  4. Do not put role="presentation" or aria-hidden="true" on a focusable element. It creates a control that can be reached but not described.
  5. Every interactive element needs an accessible name.

The three kinds of attribute

Roles - what a thing is

role="button"      role="tab"         role="tabpanel"
role="dialog"      role="alert"       role="status"
role="navigation"  role="search"      role="region"
role="tooltip"     role="menu"        role="progressbar"
role="presentation"

States - the current condition, and they change

aria-expanded="true"     aria-checked="false"
aria-selected="true"     aria-disabled="true"
aria-hidden="true"       aria-invalid="true"
aria-busy="true"         aria-pressed="true"
aria-current="page"

Properties - relationships, largely static

aria-label="Close"           aria-labelledby="heading-id"
aria-describedby="hint-id"   aria-controls="panel-id"
aria-live="polite"           aria-required="true"
aria-haspopup="true"

The handful that come up constantly

aria-expanded

<button type="button" aria-expanded="false" aria-controls="menu">Menu</button>
<ul id="menu" hidden>...</ul>
button.addEventListener("click", () => {
  const open = button.getAttribute("aria-expanded") === "true";
  button.setAttribute("aria-expanded", String(!open));
  menu.hidden = open;
});

The most commonly needed ARIA attribute, and the most commonly left stale. If the script toggles a class but not the attribute, the reader is told the menu is closed while it is open.

aria-current

<a href="/courses" aria-current="page">Courses</a>
<li aria-current="step">Step 2</li>

aria-live

Announces content that changes without the reader doing anything - a search result count, a save confirmation, a validation summary.

<!-- announced at the next natural pause -->
<p id="status" aria-live="polite"></p>

<!-- interrupts immediately: errors only -->
<p id="error" aria-live="assertive"></p>

<!-- shorthands with built in roles -->
<p role="status"></p>    <!-- equivalent to polite -->
<p role="alert"></p>     <!-- equivalent to assertive -->

The region must be in the DOM before the content changes. Inserting an element that already contains the message announces nothing - the browser only watches regions it already knows about.

// nothing is announced
document.body.insertAdjacentHTML("beforeend", "<p aria-live='polite'>Saved</p>");

// announced
document.getElementById("status").textContent = "Saved";

aria-describedby

<input id="pin" aria-describedby="pin-hint">
<small id="pin-hint">Six digits</small>

role="presentation"

<!-- strips table semantics from a legacy layout table -->
<table role="presentation">...</table>

Only correct for a layout table you cannot rewrite. The real fix is CSS grid.

Where ARIA does earn its place

  • Tabs, since HTML has no tab element.
  • Combo boxes with filtering.
  • Tree views.
  • Live regions for dynamic updates.
  • Labelling landmarks: <nav aria-label="Breadcrumb">.
  • Marking the current page or step.
  • Describing a control with hint text.

Everything else - buttons, links, headings, lists, forms, dialogs, disclosure widgets - has a native element that already does the job.

A tab interface, since HTML has none

<div role="tablist" aria-label="Course information">
  <button role="tab" id="tab-overview" aria-selected="true"
          aria-controls="panel-overview" tabindex="0">Overview</button>
  <button role="tab" id="tab-fees" aria-selected="false"
          aria-controls="panel-fees" tabindex="-1">Fees</button>
</div>

<div role="tabpanel" id="panel-overview" aria-labelledby="tab-overview" tabindex="0">
  ...
</div>
<div role="tabpanel" id="panel-fees" aria-labelledby="tab-fees" tabindex="0" hidden>
  ...
</div>

Note the tabindex pattern: the selected tab is 0 and the others are -1, so the whole tablist is one tab stop and arrow keys move between tabs. That arrow key handling has to be written in JavaScript - the markup alone does nothing.

This is what ARIA is a contract means. Declaring role="tablist" promises tab behaviour, and a screen reader user will expect it. If the behaviour is not there, the ARIA has made things worse.

Bad ARIA is worse than none

Surveys of large numbers of home pages consistently find that pages using ARIA have more detected accessibility errors on average than pages using none - because ARIA is usually added to complex interfaces, and usually added incompletely.

<!-- announced as a button, and still unusable -->
<div role="button">Save</div>

<!-- the label is a lie: voice control users say what they see -->
<button aria-label="Submit">Send enquiry</button>

<!-- reachable by keyboard, invisible to a screen reader -->
<a href="/x" aria-hidden="true">Link</a>

<!-- the heading has left the outline -->
<h2 role="presentation">Fees</h2>

Important rules

  • ARIA changes announcements, never behaviour.
  • Every ARIA widget role brings a keyboard contract you must implement.
  • Referenced ids must exist.
  • State attributes must be kept accurate.
  • Live regions must exist before their content changes.
  • Do not restate implicit roles.

Common mistakes

  • Adding roles to div elements instead of using native ones.
  • Stale aria-expanded values.
  • aria-label that disagrees with the visible text.
  • Live regions inserted along with their message.
  • Broken id references.
  • aria-hidden on focusable elements.
  • Declaring a widget role without the keyboard behaviour it implies.

Best practices

  • Native element first, every time.
  • Learn five attributes well - aria-expanded, aria-current, aria-live, aria-describedby, aria-label - rather than all of them badly.
  • Update state in the same function that updates the interface.
  • Follow the published authoring practices pattern for any widget role.
  • Test with a screen reader; ARIA cannot be verified by looking.
  • When unsure, leave it out. No ARIA beats wrong ARIA.

Practice

  1. Build a disclosure button with correct aria-expanded toggling, then break the toggle and listen to the difference.
  2. Add a polite live region for a result count and confirm it is announced on change.
  3. Insert a live region together with its message and explain why nothing is announced.
  4. Take a div role="button" and list everything missing compared with a real button.

Useful resources

Hand picked references for this topic
Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All HTML notes →

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.