---
title: 'ARIA Usage'
source: 'https://academia.sh/en/courses/frontend-quality/aria-usage'
course: 'Frontend Quality'
language: en
updated: '2026-08-17T18:11:05+00:00'
license: 'CC BY-SA 4.0'
---

# ARIA Usage

The role, state, and property declarations written into the accessibility tree; the accessible name computation in full, the rationale for the first rule, and the concrete breakages misuse produces.

The previous lesson established what native elements declare to the tree and left a gap:
components with no defined counterpart. The filter panel opens and closes, the
measurement table's columns are sortable, alert notifications appear after the fact. None
of these behaviors has a single element that corresponds to it; for them to appear
correctly in the tree, the missing declarations have to be written.

The set that writes these declarations is ARIA. The same set is the most frequently
misused tool, and when misused it makes an interface less usable than if no declaration
had been written at all. This lesson builds two things together: what the set does, and
what it breaks.

## What ARIA Changes

ARIA declarations change **only the accessibility tree**. Writing an attribute does not
add behavior to the element: it does not make it focusable, does not make it listen for
keys, does not handle clicks, does not change its appearance.

This one sentence explains the source of most misuse. Code that writes the button role
onto a `div` element produces something that looks like a button on screen and is
declared as a button in the tree, but does not respond to the space key. When declaration
and behavior diverge, the resulting state is worse than if the declaration had never been
written: the user has been told it is a button and not given a way to use it.

## Role, State, Property

The set carries three kinds of declaration.

**Role** states what the element is, and it replaces the implicit role. It is written
once and is not expected to change at runtime.

**State** is the element's condition at a given moment, and it changes with interaction:
whether it is expanded (`aria-expanded`), checked (`aria-checked`), selected
(`aria-selected`), busy (`aria-busy`), invalid (`aria-invalid`).

**Property** is a more lasting characteristic of the element: its name (`aria-label`,
`aria-labelledby`), its description (`aria-describedby`), what it controls
(`aria-controls`), whether it is required (`aria-required`), which order it is sorted in
(`aria-sort`).

The distinction is not cosmetic: state declarations **must be updated as they change**.
If the `aria-expanded` value is not updated when the panel opens, the tree carries
information that contradicts what is on screen. Wrong information costs more than missing
information.

## The First Rule

The first of ARIA's writing rules is this: **if a native element carrying the required
meaning exists, ARIA is not used.** Instead of writing the button role onto a `div`
element, `button` is written; instead of the list role, `ul` is used; instead of the
heading role, a heading element is used.

The rationale is the accounting from the previous lesson. A native element brings role,
name, focusability, key behavior, state declarations, and form relationship together; a
role declaration brings only the first of these. Writing the remaining five by hand is
both longer and a task where a new mistake can be made in every implementation.

The remaining rules are extensions of this first one: a native element's meaning is not
changed unless necessary; every interactive element with a written role must be operable
by keyboard; a focusable element is not hidden from the tree; every interactive element
must have an accessible name.

## The Full Name Computation

The previous lesson's name computation was limited to local sources. ARIA sources sit at
the very top of the order and suppress everything local. The script below implements the
full order and runs a rule audit over the same set of markup.

```js
// aria.mjs — full accessible name computation (ARIA sources on top) and misuse audit

const INTERACTIVE = new Set(["button", "link", "checkbox", "textbox", "searchbox", "spinbutton"]);
const FROM_CONTENT = new Set(["button", "link", "heading", "cell", "columnheader", "checkbox", "listitem"]);
const NO_AUTHOR_NAME = new Set(["generic", "presentation", "none", "paragraph"]);   // does not accept an author-provided name

function role(d) {
  const a = d.attrs ?? {};
  if (a.role) return a.role;
  switch (d.element) {
    case "a": return a.href === undefined ? "generic" : "link";
    case "button": return "button";
    case "input": return a.type === "checkbox" ? "checkbox" : a.type === "search" ? "searchbox" : "textbox";
    case "table": return "table";
    case "section": return a["aria-label"] || a["aria-labelledby"] ? "region" : "generic";
    case "h2": return "heading";
    default: return "generic";
  }
}

const clean = (s) => s.replace(/\s+/g, " ").trim();

// Content text; aria-hidden subtree is skipped
function text(d, visible = true) {
  if (typeof d === "string") return d;
  const a = d.attrs ?? {};
  if (visible && (a["aria-hidden"] === true || a.hidden)) return "";
  if (d.element === "img") return a.alt ?? "";
  return clean((d.children ?? []).map((c) => text(c, visible)).join(" "));
}

// 1 aria-labelledby -> 2 aria-label -> 3 local source -> 4 content -> 5 last resort
function name(d, doc) {
  const a = d.attrs ?? {};
  const r = role(d);
  const takesAuthorName = !NO_AUTHOR_NAME.has(r);

  if (takesAuthorName && a["aria-labelledby"]) {
    const parts = a["aria-labelledby"].split(" ").map((k) => doc[k]).filter(Boolean);
    // the text of a referenced node is used even if the node itself is hidden
    if (parts.length > 0) return [clean(parts.map((p) => text(p, false)).join(" ")), "aria-labelledby"];
  }
  if (takesAuthorName && a["aria-label"]) return [clean(a["aria-label"]), "aria-label"];
  if (["input", "select", "textarea"].includes(d.element) && doc["label:" + a.id]) {
    return [text(doc["label:" + a.id]), "label element"];
  }
  if (FROM_CONTENT.has(r)) {
    const m = text(d);
    if (m) return [m, "content text"];
  }
  return ["", "no name"];
}

// --- North Slope: filter panel and measurement table components -------------
const DOC = {
  "heading": { element: "h2", children: ["Filter"] },
  "count": { element: "span", children: ["3 criteria"] },
  "hidden-name": { element: "span", attrs: { hidden: true }, children: ["Delete measurement row"] },
  "label:code": { element: "label", children: ["Station code"] },
};

const EXAMPLE = [
  ['<button>Filter</button>',
   { element: "button", children: ["Filter"], focusable: true }],
  ['<button aria-label="Save">Delete measurement</button>',
   { element: "button", attrs: { "aria-label": "Save" }, children: ["Delete measurement"], focusable: true }],
  ['<button aria-labelledby="heading count">Open</button>',
   { element: "button", attrs: { "aria-labelledby": "heading count" }, children: ["Open"], focusable: true }],
  ['<button aria-labelledby="no-such-id">Delete</button>',
   { element: "button", attrs: { "aria-labelledby": "no-such-id" }, children: ["Delete"], focusable: true }],
  ['<div role="button" tabindex="0">Delete</div>',
   { element: "div", attrs: { role: "button" }, children: ["Delete"], focusable: true }],
  ['<div role="button">Delete</div>',
   { element: "div", attrs: { role: "button" }, children: ["Delete"], focusable: false }],
  ['<input id="code" aria-label="Code"> + label',
   { element: "input", attrs: { id: "code", "aria-label": "Code" }, focusable: true }],
  ['<div aria-label="Filter panel">…</div>',
   { element: "div", attrs: { "aria-label": "Filter panel" }, children: ["3 criteria"], focusable: false }],
  ['<section aria-label="Filter panel">…',
   { element: "section", attrs: { "aria-label": "Filter panel" }, children: ["3 criteria"], focusable: false }],
  ['<button aria-hidden="true">Export</button>',
   { element: "button", attrs: { "aria-hidden": true }, children: ["Export"], focusable: true }],
  ['<button aria-labelledby="hidden-name">×</button>',
   { element: "button", attrs: { "aria-labelledby": "hidden-name" }, children: ["×"], focusable: true }],
  ['<table role="presentation">…</table>',
   { element: "table", attrs: { role: "presentation" }, children: ["Temperature"], focusable: false }],
];

console.log("markup".padEnd(55) + "role".padEnd(14) + "accessible name".padEnd(27) + "source");
for (const [markup, d] of EXAMPLE) {
  const [n, from] = name(d, DOC);
  console.log(markup.padEnd(55) + role(d).padEnd(14) + (n === "" ? "(empty)" : '"' + n + '"').padEnd(27) + from);
}

console.log("\nrule audit:");
let count = 0;
for (const [markup, d] of EXAMPLE) {
  const a = d.attrs ?? {};
  const r = role(d);
  const [n] = name(d, DOC);
  const visible = text(d);
  const findings = [];
  if (a["aria-hidden"] === true && d.focusable) findings.push("focusable element hidden from tree");
  if (a["aria-labelledby"] && !a["aria-labelledby"].split(" ").some((k) => DOC[k]))
    findings.push("aria-labelledby references an invalid id");
  if ((a["aria-label"] || a["aria-labelledby"]) && NO_AUTHOR_NAME.has(r))
    findings.push("name declaration ignored on a role that does not accept an author name");
  if (INTERACTIVE.has(r) && !d.focusable) findings.push("interactive role is not focusable");
  if (INTERACTIVE.has(r) && n === "") findings.push("interactive element has no name");
  if (["presentation", "none"].includes(a.role) && ["table", "ul", "ol", "li"].includes(d.element))
    findings.push("native structural meaning removed: row and column relationships absent from the tree");
  if (INTERACTIVE.has(r) && /\p{L}/u.test(visible) && n &&
      !n.toLocaleLowerCase("en-US").includes(visible.toLocaleLowerCase("en-US")))
    findings.push('visible text ("' + visible + '") is not contained in the accessible name');
  for (const f of findings) {
    count++;
    console.log("  " + markup.padEnd(55) + f);
  }
}
console.log(`\n${EXAMPLE.length} examples, ${count} findings`);
```

```
markup                                                 role          accessible name            source
<button>Filter</button>                                button        "Filter"                   content text
<button aria-label="Save">Delete measurement</button>  button        "Save"                     aria-label
<button aria-labelledby="heading count">Open</button>  button        "Filter 3 criteria"        aria-labelledby
<button aria-labelledby="no-such-id">Delete</button>   button        "Delete"                   content text
<div role="button" tabindex="0">Delete</div>           button        "Delete"                   content text
<div role="button">Delete</div>                        button        "Delete"                   content text
<input id="code" aria-label="Code"> + label            textbox       "Code"                     aria-label
<div aria-label="Filter panel">…</div>                 generic       (empty)                    no name
<section aria-label="Filter panel">…                   region        "Filter panel"             aria-label
<button aria-hidden="true">Export</button>             button        (empty)                    no name
<button aria-labelledby="hidden-name">×</button>       button        "Delete measurement row"   aria-labelledby
<table role="presentation">…</table>                   presentation  (empty)                    no name

rule audit:
  <button aria-label="Save">Delete measurement</button>  visible text ("Delete measurement") is not contained in the accessible name
  <button aria-labelledby="heading count">Open</button>  visible text ("Open") is not contained in the accessible name
  <button aria-labelledby="no-such-id">Delete</button>   aria-labelledby references an invalid id
  <div role="button">Delete</div>                        interactive role is not focusable
  <div aria-label="Filter panel">…</div>                 name declaration ignored on a role that does not accept an author name
  <button aria-hidden="true">Export</button>             focusable element hidden from tree
  <button aria-hidden="true">Export</button>             interactive element has no name
  <table role="presentation">…</table>                   native structural meaning removed: row and column relationships absent from the tree

12 examples, 8 findings
```

The `focusable` field in the script represents whether the element actually takes focus;
for the `div` rows it corresponds to whether `tabindex` was written.

## Reading the Findings

**A name declaration suppresses the visible text.** In the second row the button reads
"Delete measurement" on screen, but its name in the tree is "Save." A person using the
interface by voice says the text they see, and the name they say matches no element.
Criterion 2.5.3 forbids exactly this: the visible label text must be contained within the
accessible name. The same breakage happens more subtly in the seventh row — the field has
a `label` element, but because the `aria-label` declaration suppresses it, a field that
reads "Station code" on screen has the name "Code."

**An invalid reference drops silently.** In the fourth row `aria-labelledby` points to an
id that does not exist; the computation skips that step and the name comes from content
text instead. The result looks harmless in this example, but the intended declaration has
been ignored. On an icon button with no content, the same mistake leaves a nameless
button.

**A reference to a hidden element is valid.** The eleventh row shows this: the text
inside a hidden `span` becomes the name of the button that references it. This is the
defined way to give a name to a delete button that shows only an × on screen.

**Some roles do not accept an author-provided name.** In the eighth row an `aria-label`
written on a `div` element is ignored; in the ninth row, the same declaration written on
a `section` element both turns the element's role into a region and produces a name. A
name declaration is written against the element's role, not the element.

**A focusable element cannot be hidden from the tree.** The tenth row produces two
findings at once: the button takes focus but is absent from the tree. A user navigating
by keyboard hits a stop where focus disappears; the tab key goes somewhere, and nothing is
declared there.

**Removing structural meaning removes data.** In the last row the table's role has been
converted to presentational. For a table used for layout, that is the right decision; in
a data-bearing measurement table, row and column relationships drop out of the tree
entirely, and a user moving between cells cannot learn which column they are in.

## Correct Usage in the Filter Panel

The markup below declares two structures that no native element covers, according to the
rule: a panel that opens and closes, and a sortable column.

```html
<h2 id="filter-heading">Filter</h2>
<button aria-expanded="false" aria-controls="filter-body">
  Filter criteria
</button>
<div id="filter-body" hidden>
  <label for="filter-code">Station code</label>
  <input id="filter-code" type="search">
  <p><label><input type="checkbox" name="alert"> Only measurements with an alert</label></p>
</div>

<table aria-labelledby="table-heading">
  <caption id="table-heading">Daily measurements</caption>
  <thead>
    <tr>
      <th scope="col" aria-sort="descending">
        <button>Date</button>
      </th>
      <th scope="col" aria-sort="none"><button>Temperature</button></th>
    </tr>
  </thead>
</table>
```

Three decisions deserve attention. The disclosure control is a `button` element; no role
is written, because the required role already exists. The `aria-expanded` state must be
updated every time the panel opens and closes; if it stays fixed, the tree carries wrong
information. The sort state is written on the header cell, not on the button inside it:
what is being sorted is the column, and the button is only the control that starts the
sort.

## Summary

- ARIA declarations change only the accessibility tree; they add no behavior,
  focusability, or appearance.
- Role declares what the element is, state its current condition, property its more
  lasting characteristics; state declarations must be updated together with interaction.
- The first rule is not using ARIA when a native element carrying the required meaning
  exists; a native element brings name, focus, key behavior, and states along with role.
- In the name computation, ARIA sources suppress local sources; a divergence between the
  visible text and the accessible name fails criterion 2.5.3.
- An invalid id reference drops silently, a reference to a hidden element is valid, and
  `aria-label` is ignored on roles that do not accept an author name.
- Hiding a focusable element from the tree and removing a data table's structural meaning
  produce breakages more severe than writing no declaration at all.

## Next Step

One row of the rule audit was left unopened in this lesson: an element with the button
role written but not taking focus. It is not enough for the declaration to be correct;
the element must also be operable by keyboard, and these two requirements are defined in
separate criteria. The next lesson looks at the whole interface from the keyboard: which
elements are in the tab order, how the order is computed, how an opened panel contains
focus, and at what point that containment turns into a trap.
