---
title: 'The Role of Semantic Markup'
source: 'https://academia.sh/en/courses/frontend-quality/role-of-semantic-markup'
course: 'Frontend Quality'
language: en
updated: '2026-08-17T18:11:05+00:00'
license: 'CC BY-SA 4.0'
---

# The Role of Semantic Markup

How the accessibility tree is derived from the document tree, the implicit roles native elements produce, the order in which the accessible name is computed, and an accounting of the cost paid for a meaningless container.

The previous lesson put criterion 4.1.2 on the list: an interface component's **name**,
**role**, and **value** must be programmatically readable. For the criterion to be
testable, this triple must actually exist somewhere. A button's name does not live in the
page's visual appearance; it lives in a second structure derived from the document.

That structure is the **accessibility tree** introduced in the Semantic Markup and Media
topic. This lesson takes up how the tree is derived at the rule level: which node enters
the tree, where the role of one that enters comes from, and in what order its name is
computed. All three answers live in the markup; none is read from the presentation layer.

## The Tree Is Derived From the Tree

The browser walks the Document Object Model tree and builds a second tree. The second
tree's nodes are not the elements themselves but their **accessibility counterparts**:
each node carries a role, a name, a description, and a set of states.

Some nodes never enter the tree during derivation. The pruning rules are these: elements
not rendered in the presentation layer or whose visibility has been removed do not enter;
elements carrying the `hidden` attribute do not enter; an element declaring
`aria-hidden="true"` and its whole subtree do not enter; an image whose alternative text
has been left empty is treated as presentational and does not enter.

This produces a distinction. There are two ways to remove an element from the screen, and
they give different results in the tree. Taking an element out of rendering in the
presentation layer also deletes it from the tree; moving it off-screen or making it
transparent keeps it in the tree. A skip link being able to work while invisible rests on
the second path: the link is in the tree, it takes focus, and it is only invisible to the
eye.

## Implicit Role

Every node that enters the tree has a role, and when no declaration is written, that role
comes from the element itself. This is called the **implicit role**. Part of the mapping
is conditional: the same element produces a different role depending on an attribute.

The `a` element produces the link role only when it carries an `href` attribute; without
one it is a meaningless node in the tree. The `input` element's role comes from its type
attribute: a text field, a search field, a number field, and a checkbox are separate
roles. The `th` element's role is column header or row header depending on its scope
attribute. The mapping from sectioning elements to landmark roles, and the conditions on
that mapping, were established in the Web Fundamentals and HTML course; here the same
derivation continues for form controls.

## Computing the Accessible Name

The question after role is name. The **accessible name** is the string that introduces a
node to the user, and it does not come from a single source: it is computed by a defined
order of precedence. The script below implements the local part of that order. ARIA
sources sit at the very top of the order and are the next lesson's subject; here we see
what happens without them.

```js
// name.mjs — implicit role of native elements and accessible name computation (ARIA sources excluded)

function role(d) {
  const a = d.attrs ?? {};
  switch (d.element) {
    case "a": return a.href === undefined ? "generic" : "link";
    case "button": return "button";
    case "input":
      return { checkbox: "checkbox", radio: "radio", number: "spinbutton",
               search: "searchbox", submit: "button" }[a.type ?? "text"] ?? "textbox";
    case "select": return "combobox";
    case "table": return "table";
    case "th": return a.scope === "row" ? "rowheader" : "columnheader";
    case "td": return "cell";
    case "tr": return "row";
    case "fieldset": return "group";
    case "img": return a.alt === "" ? "— (presentational)" : "image";
    case "ul": return "list";
    case "li": return "listitem";
    case "h1": case "h2": case "h3": return "heading";
    case "output": return "status";
    default: return "generic";
  }
}

// roles that take a name from content in ARIA
const FROM_CONTENT = new Set(["button", "link", "heading", "cell", "columnheader",
  "rowheader", "listitem", "checkbox", "radio", "option"]);

function text(d) {
  if (typeof d === "string") return d;
  const a = d.attrs ?? {};
  if (a.hidden) return "";
  if (d.element === "img") return typeof a.alt === "string" ? a.alt : "";
  return (d.children ?? []).map(text).join(" ").replace(/\s+/g, " ").trim();
}

// Order: local source -> content text (roles that allow it) -> last resort
function name(d, labels) {
  const a = d.attrs ?? {};
  if (a.hidden) return ["", "hidden"];
  if (d.element === "img" && typeof a.alt === "string" && a.alt !== "") return [a.alt, "alt"];
  for (const [el, sub] of [["table", "caption"], ["fieldset", "legend"], ["figure", "figcaption"]]) {
    if (d.element !== el) continue;
    const c = (d.children ?? []).find((x) => x.element === sub);
    if (c) return [text(c), sub];
  }
  if (["input", "select", "textarea"].includes(d.element)) {
    if (labels[a.id]) return [labels[a.id], "label element"];
    if (a.title) return [a.title, "title (last resort)"];
    if (a.placeholder) return [a.placeholder, "placeholder (last resort)"];
    return ["", "no name"];
  }
  if (FROM_CONTENT.has(role(d))) {
    const m = text(d);
    if (m) return [m, "content text"];
  }
  if (a.title) return [a.title, "title (last resort)"];
  return ["", "no name"];
}

// --- North Slope: examples from the filter panel and the measurement table --
const LABELS = {
  "filter-code": "Station code",
  "filter-alert": "Only measurements with an alert",
  value: "Measured value (°C)",
};

const EXAMPLE = [
  ["<button>Filter</button>", { element: "button", children: ["Filter"] }],
  ['<button><img alt="Clear filter"></button>',
   { element: "button", children: [{ element: "img", attrs: { alt: "Clear filter" } }] }],
  ['<button><img alt=""></button>',
   { element: "button", children: [{ element: "img", attrs: { alt: "" } }] }],
  ['<input id="filter-code" type="search"> + label',
   { element: "input", attrs: { id: "filter-code", type: "search" } }],
  ['<input type="search" placeholder="station">',
   { element: "input", attrs: { id: "search", type: "search", placeholder: "station" } }],
  ['<input id="value" type="number"> + label',
   { element: "input", attrs: { id: "value", type: "number" } }],
  ['<input id="filter-alert" type="checkbox"> + label',
   { element: "input", attrs: { id: "filter-alert", type: "checkbox" } }],
  ["<table><caption>Daily measurements</caption>",
   { element: "table", children: [{ element: "caption", children: ["Daily measurements"] }] }],
  ["<fieldset><legend>Measurement source</legend>",
   { element: "fieldset", children: [{ element: "legend", children: ["Measurement source"] }] }],
  ['<a href="/stations/">Stations</a>',
   { element: "a", attrs: { href: "/stations/" }, children: ["Stations"] }],
  ["<a>Stations</a>", { element: "a", children: ["Stations"] }],
  ['<div class="button">Save</div>', { element: "div", children: ["Save"] }],
  ['<th scope="col">Temperature</th>',
   { element: "th", attrs: { scope: "col" }, children: ["Temperature"] }],
  ['<img src="alert.svg" alt="">', { element: "img", attrs: { alt: "" } }],
];

console.log("markup".padEnd(51) + "role".padEnd(20) + "accessible name".padEnd(35) + "source");
for (const [markup, node] of EXAMPLE) {
  const [n, from] = name(node, LABELS);
  console.log(
    markup.padEnd(51) + role(node).padEnd(20) +
    (n === "" ? "(empty)" : '"' + n + '"').padEnd(35) + from,
  );
}
```

```
markup                                             role                accessible name                    source
<button>Filter</button>                            button              "Filter"                           content text
<button><img alt="Clear filter"></button>          button              "Clear filter"                     content text
<button><img alt=""></button>                      button              (empty)                            no name
<input id="filter-code" type="search"> + label     searchbox           "Station code"                     label element
<input type="search" placeholder="station">        searchbox           "station"                          placeholder (last resort)
<input id="value" type="number"> + label           spinbutton          "Measured value (°C)"              label element
<input id="filter-alert" type="checkbox"> + label  checkbox            "Only measurements with an alert"  label element
<table><caption>Daily measurements</caption>       table               "Daily measurements"               caption
<fieldset><legend>Measurement source</legend>      group               "Measurement source"               legend
<a href="/stations/">Stations</a>                  link                "Stations"                         content text
<a>Stations</a>                                    generic             (empty)                            no name
<div class="button">Save</div>                     generic             (empty)                            no name
<th scope="col">Temperature</th>                   columnheader        "Temperature"                      content text
<img src="alert.svg" alt="">                       — (presentational)  (empty)                            no name
```

There are three places in the table worth reading closely.

**Content only produces a name for certain roles.** The set in the script enforces this:
button, link, heading, and cell roles take their name from their inner text; text field,
table, and group roles do not. That is why the text `Stations` produces a name inside a
link, while the same text inside an `a` element without an `href` produces nothing. The
distinction is not arbitrary: the text inside a text field is its **value**, not its
name; if the two were conflated, what the user typed would be mistaken for the field's
name.

**Text alternative traversal collects the names of child nodes.** The button that clears
the filter has no text inside it, only an image; the image's alternative text becomes the
button's name. The third row is the reverse case: when the same button's alternative text
is left empty, the image is treated as presentational and pruned, leaving a nameless
button. An icon appears on screen; the tree has no name. This is the most common defect
in icon buttons, and it directly fails criterion 4.1.2.

**Last-resort sources turn into a name but do not do the job.** The search field with
placeholder text gains a name. That name is text that disappears from the screen once the
field is filled, and it also carries the low contrast measured in the Contrast and
Accessibility lesson. The computation accepting it as a name does not mean the design may
put it in place of a label. The same is true of the `title` attribute: it exists in the
computation, it depends on a pointing device, and it is no substitute for a persistent
label.

## Value and State

The last member of the triple is value, and it varies by role: a text field's value is
the text typed into it, a checkbox's value is whether it is checked, and a number field's
value is the number itself along with its lower and upper bounds. Alongside these are
**states**: disabled, required, invalid, read-only, expanded.

In native elements, all of these declarations come from attributes and are never written
separately. When `required` is written, the requirement passes into the tree; when
`disabled` is written, both the enabled state is declared and the element drops out of
focus; when `min` and `max` are written on a number field, the value range appears in the
tree. This is why constraint validation in the Web Fundamentals and HTML course does two
jobs at once: it declares the rule to the browser and the state to the accessibility
tree.

## The Cost of a Meaningless Container

When the button that deletes a row of the measurement table is built with a `div`
element, it looks the same on screen. In the tree, six declarations are missing at once,
and each one has to be put back by hand: the role (that it is a button), the accessible
name, focusability, operability with the space and enter keys, the declaration of the
disabled state, and its relationship to form submission.

Five of these six items are recovered by changing a single element name in the markup.
Preferring a native element is not a matter of style; it drives the number of
declarations that must be reproduced down to zero. The same accounting applies to a
table: in a grid built from a stack of `div` elements, row and column relationships,
which data a header cell belongs to, and the total row count never reach the tree at all;
a user moving between cells cannot hear the name of the column they are in.

The decision criterion is this: if a defined element exists for the job, it is used. When
no defined element exists — a tabbed panel, a tree view, a combobox — the missing
declarations must be written by hand, and ARIA is the tool for that.

## Summary

- The accessibility tree is derived from the document tree; nodes that are not rendered,
  carry `hidden`, declare `aria-hidden`, or are images with empty alternative text are
  pruned.
- The implicit role comes from the element and is conditional: `a` is a link only if it
  carries `href`, `input`'s role is determined by its type, and `th`'s role is determined
  by its scope.
- The accessible name is computed by a defined order; content only produces a name for
  roles that allow it, and text alternative traversal collects the alternative text of
  child nodes.
- A button containing an icon with empty alternative text stays nameless; a placeholder
  and `title` produce a name in the computation but are no substitute for a persistent
  label.
- Value and state declarations come from attributes on native elements; a control built
  from a meaningless container must have its role, name, focusability, key behavior, and
  state produced by hand.

## Next Step

The last section left a gap: how is a component declared when it has no defined element?
The filter panel above the measurement list is a section that opens and closes, alert
notifications appear on their own, and the measurement table has sortable columns. None
of these has a native counterpart, and all three must appear correctly in the tree. The
next lesson takes up the set of declarations that fills this gap — roles, states, and
properties — and shows, using the same name computation, how that set breaks the tree
when it is used incorrectly.
