Accessibility and compatibilitySemantics and the accessibility tree

100%

Semantics and the accessibility tree.

A screen reader never looks at your page. It asks the operating system about a second, quieter tree that the browser keeps beside the DOM, where every node says what it is, what it is called and what state it is in. Write HTML that fills that tree honestly and most of accessibility follows; paint a control out of divs and the tree records nothing but text.

Beginner38 minUpdated 2 Oct 2026

The idea.

Two controls can look identical on screen and be completely different things to a screen reader. What decides it is a tree you never see, built from the elements you chose.

At the bottom of Railmoor's passenger details page sits a teal bar that says Continue to seats. A sighted traveller with a mouse sees a button and clicks it. Now picture the same page for someone who is blind and uses a screen reader, someone who steers the computer by voice ("click Continue"), and someone on a single switch that steps through the controls one at a time. None of them is looking at that teal bar. Each of their tools asks the operating system: what is on this page, what can I act on, and what is each thing called?

The operating system answers from the accessibility tree, a structure the browser derives from the DOM and keeps up to date as the page changes. It is a pruned copy: wrapper divs that only exist for layout disappear, and each remaining node carries a small record. A role says what kind of thing it is (button, link, checkbox, heading). A name says what it is called (Continue to seats). States and properties say how it is right now (expanded, checked, disabled, invalid), and a value holds what the user typed or picked.

If Railmoor's developer wrote that bar as a div with a click handler, the tree records a generic node that happens to contain some text. Nothing says it can be pressed, the Tab key never stops on it, and a voice command for "Continue" finds no control to click. Written as a button, the same pixels become a node with role button and name Continue to seats, which the browser also makes focusable and activatable from the keyboard. The visual design did not change at all; the meaning did.

The same teal bar, written three ways

<div class="cta cta--primary" onclick="goToSeats()">
  Continue to seats
</div>
<!-- role and tabindex make the tree say "button"…
     but Enter and Space still do nothing: ARIA adds no behaviour -->
<div class="cta cta--primary" role="button" tabindex="0"
     onclick="goToSeats()">
  Continue to seats
</div>
<form action="/booking/seats" method="post">
  …
  <button class="cta cta--primary" type="submit">
    Continue to seats
  </button>
</form>

What the accessibility tree records for each version

VersionRoleNameReached by TabEnter / SpaceA screen reader says
div + onclickgeneric(none)NoNothingOnly the words, as plain text while reading the page; never "button"
div role=button tabindex=0button"Continue to seats"YesNothing, unless you add a keydown handler"Continue to seats, button", then the key press fails
button type=submitbutton"Continue to seats"YesSubmits the form"Continue to seats, button"

The four things every control must answer

Role
What kind of thing is this? A button, link, checkbox, combobox, heading or landmark. The role tells the user which keys and commands apply, and lets them jump between things of the same kind.
Name
What is it called? The words a screen reader speaks and a voice user says out loud: Continue to seats, Email address, Seat 3D. An unnamed control is announced as just "button" or "edit text".
State and properties
How is it right now? Checked or not, expanded or collapsed, selected, disabled, required, invalid. When a state changes the browser raises an event, so the change can be heard as well as seen.
Value
What does it hold? The text typed into Email, the station chosen in From, the position of a slider. Inputs and ranges have a value; buttons and links do not.

The requirement behind it

Railmoor's contract says the site must meet WCAG 2.2 at level AA, the level that accessibility regulations and contracts most often name. WCAG is organised under four principles, often shortened to POUR: content must be perceivable (it can be seen, heard or felt in some form), operable (every function works with the input the person has), understandable (labels, errors and behaviour make sense) and robust (it works with today's and tomorrow's browsers and assistive technology). Under them sit 13 guidelines and, under those, testable success criteria at three levels.

Level A is the floor, AA adds the criteria most products are held to, and AAA is the strictest. Conforming at AA means meeting every A and every AA criterion on the page; the W3C itself advises against demanding AAA for an entire site, because some content cannot meet all of it. This topic owns the criteria about structure and naming: 1.1.1 text alternatives, 1.3.1 info and relationships, 2.4.6 headings and labels, 3.3.1 and 3.3.2 errors and labels, and above all 4.1.2 Name, Role, Value, which is the accessibility tree written as a rule.

POUR on Railmoor's booking journey

PrincipleAsksWhere Railmoor meets itTopic
PerceivableCan every user get the information in a form they can sense?Alt text on the route map; seat status in words, not only in colour; contrast of the teal barThis topic (text alternatives); contrast in Announcing changes and preferences
OperableCan every user drive every function?Continue reachable and pressable from the keyboard; seat map crossed with arrow keysKeyboard access and focus management
UnderstandableDo labels, errors and behaviour make sense?Every field labelled; an error says what is wrong and how to fix itThis topic
RobustCan browsers and assistive technology parse it reliably, now and later?Native elements first; correct role, name and state on every custom widget (4.1.2)This topic; old kiosk browsers in Browser and device support
Was this section helpful?

How it works.

The browser turns markup into a tree of roles, names and states; the operating system hands that tree to assistive technology; and every source of a name has a fixed rank.

From the DOM to the accessibility tree

Who reads what, from markup to speech

Who reads what, from markup to speech. The numbered component cards that follow describe each part.
Who reads what, from markup to speechComponents: 1. Railmoor's HTML and ARIA (The markup Railmoor ships, with the CSS that hides or shows parts of it. The only input the accessibility tree has.), 2. Browser accessibility tree (Built and kept in sync by the browser beside the DOM. Prunes nodes with no meaning and computes each node's role, name, states and value.), 3. Platform accessibility API (The operating system's interface for assistive technology (UI Automation and IAccessible2 on Windows, NSAccessibility on macOS, AT-SPI on Linux). Carries queries in and change events out.), 4. Screen reader (NVDA, JAWS, VoiceOver, TalkBack and others. Speaks or brailles what the tree says and sends the user's commands back as actions.).

Operating system

Browser

derive

expose

answers and events

actions: press, focus

1Railmoor's HTML and ARIA
<button>, <label>, aria-*
CSS display and visibility

2Browser accessibility tree
role, name, states, value
pruned
kept in sync

3Platform accessibility API
queries in
events out

4Screen reader
speech
braille
commands

4 steps. Step through to see what one change does to these components.

Railmoor's passenger details page as the accessibility tree

RootWebArea "Passenger details · Railmoor"
  banner
    link "Railmoor home"
    navigation "Booking steps"
      list
        listitem  link "1 Journey"
        listitem  StaticText "2 Passengers" (current step)
        listitem  StaticText "3 Seats"
        listitem  StaticText "4 Payment"
  main
    form "Who is travelling?"
      heading "Who is travelling?" level=1
      group "Passenger 1 (adult)"
        textbox "First name" required value="Amara"
        textbox "Surname" required value=""
        textbox "Email for your tickets" required invalid
            description="Enter an email address like name@example.com"
        checkbox "I need a wheelchair space" checked=false
      button "Continue to seats"
  contentinfo
    link "Accessibility statement"
<label for="email">Email for your tickets</label>
<input id="email" type="email" name="email"
       autocomplete="email" required
       aria-invalid="true" aria-describedby="email-error">
<p id="email-error" class="field-error">
  Enter an email address like name@example.com
</p>

What reaches the tree and what does not

Markup or CSSIn the tree?Why it matters
<div class="row"> with no role or namePruned (generic, ignored)Wrappers cost nothing to assistive technology; you do not have to avoid divs, only avoid using them as controls
display: none, the hidden attributeRemoved with its subtreeClosed menus and inactive tabs vanish for everyone, which is usually what you want
visibility: hiddenRemoved with its subtreeSame as display: none for assistive technology, though it still takes up space
aria-hidden="true"Removed with its subtree, still visibleFor decorative duplicates (an icon beside its own text). On anything focusable it creates a control that Tab reaches and the screen reader cannot name
.visually-hidden (clipped to 1 px)KeptThe standard way to give a screen reader text that sighted users do not need, such as "(opens in a new tab)"
opacity: 0KeptInvisible but still read and still focusable: a common source of ghost controls
<img alt="">Pruned (presentation)The right choice for a purely decorative image
inertRemoved, and cannot be focused or clickedThe background behind a modal; see Keyboard access and focus management

How a name is computed

A control can carry several possible names at once: a label element, an aria-label, the words inside it, a title tooltip, a placeholder. The browser does not merge them. It walks a fixed list of sources from strongest to weakest and stops at the first one that produces text. That list is defined by the Accessible Name and Description Computation spec (AccName) and, for HTML elements, by the HTML Accessibility API Mappings.

In order: aria-labelledby (text pulled from other elements by id, even hidden ones), then aria-label (a string on the element itself), then the native label the host language provides (a label element for an input, alt for an image, legend for a fieldset, caption for a table), then the element's own content, but only for roles that may be named that way (buttons, links, headings, options, cells; never text boxes or landmarks), then the title attribute, and for text inputs the placeholder as a last resort. The stronger sources override the weaker ones silently, which is how a well-meant aria-label can hide a perfectly good visible label.

Which source wins the name, control by control

Name sources
  1. aria-labelledby, width 1, Not present
  2. aria-label, width 1, Not present
  3. label / alt / legend, width 1, Gives the name
  4. content, width 1, Not a source for this role
  5. title, width 1, Not present
  6. placeholder, width 1, Not present
  • Gives the name
  • Present but ignored
  • Not present
  • Not a source for this role
Start

As it starts. 6 steps follow.

The sources in AccName and HTML-AAM order, strongest on the left. Step through Railmoor's controls: the first source with text is used and everything to its right is ignored, even when it is visible on screen.

The same sources in markup

<!-- label element: the default for every form field -->
<label for="email">Email for your tickets</label>
<input id="email" type="email" placeholder="name@example.com">

<!-- content: buttons and links are named by their text -->
<button type="submit">Continue to seats</button>

<!-- aria-label: an icon-only control has no text to use -->
<button type="button" aria-label="Swap From and To">
  <svg aria-hidden="true" focusable="false">…</svg>
</button>

<!-- aria-labelledby: reuse visible text that lives elsewhere -->
<dialog aria-labelledby="summary-title">
  <h2 id="summary-title">Your booking</h2>
  …
</dialog>

<!-- alt: the host language's label for an image -->
<img src="/maps/kellbridge-ashcombe.svg"
     alt="Route map: Kellbridge Central to Ashcombe Junction, 4 stops">

Name, description and the words in between

PropertyComes fromWhen the screen reader says itRailmoor example
Namearia-labelledby, aria-label, label, content, titleEvery time the control is reached or listedEmail for your tickets
Roleelement, or role=Right after the nameedit text, button, checkbox
Statesrequired, checked, aria-expanded, aria-invalid…After the role, and again whenever they changerequired, invalid entry
Descriptionaria-describedby (or aria-errormessage when invalid)After a short pause, or on request; some users turn it offEnter an email address like name@example.com

Roles come from elements; ARIA only relabels them

What a native element gives you for nothing

ElementImplicit roleAlso built in
<button>buttonFocusable; Enter and Space activate; disabled takes it out of the Tab order and is announced as unavailable or dimmed; submits its form
<a href>linkFocusable; Enter follows; middle-click, copy link and "list links" in screen readers. Without href it is generic
<input type="checkbox">checkboxSpace toggles; checked state announced on change; label click toggles it too
<select>combobox (single) or listboxKeyboard and type-ahead selection; the platform picker on phones and kiosks
<h1> … <h6>heading, level 1 to 6Appears in the headings list; level drives the outline
<nav>, <main>, <aside>navigation, main, complementaryAppear in the landmarks list; one keystroke jumps to each
<ul>, <ol>list"List, 3 items": the user hears how much is coming
<table> with <th>table, columnheader, rowheaderCell navigation with each cell announced with its headers
<dialog> via showModal()dialogTop layer, inert background, Escape to close (focus handling in Keyboard access and focus management)

The rules of ARIA, in practice

Use the native element when one exists.
Do not override native semantics without a reason.
Every interactive ARIA role brings a keyboard contract you must implement.
Never hide something focusable.
Every interactive element needs an accessible name.
No ARIA is better than bad ARIA.

Landmarks and headings are the page's table of contents

Sighted users skim: their eyes jump to the big heading, the seat map, the price box. Screen reader users skim too, through lists the screen reader builds from the tree. A landmarks list holds the page's regions (banner, navigation, main, complementary, contentinfo, plus any named form or region), and a single key moves between them. A headings list shows every heading with its level, and another key jumps heading by heading. Both lists exist only if the markup created them.

Two details trip people up. A header or footer element is the page's banner or contentinfo only when it is scoped to the body; nested inside main, article, aside, nav or section it is no longer a page landmark. A section or form element becomes a landmark only once it has an accessible name, usually through aria-labelledby pointing at its heading. For headings, keep one h1 for the page's purpose, never skip a level going down (an h2 followed by an h4 suggests a missing section), and choose the level for structure, not for font size, which CSS can set independently.

Railmoor's seat page as landmarks and headings

Railmoor's seat page as landmarks and headings. The numbered component cards that follow describe each part.
Railmoor's seat page as landmarks and headingsComponents: 6. Seat map (coach C) (8 rows of 4 seats as role=grid. Each seat says its number, position and availability in words, and whether it is chosen.).

contentinfo<footer> in body

link "Accessibility statement"

complementary "Journey"<aside>

h2 "Your journey"

main<main>, one per page

h1 "Choose your seats"

h2 "Coach C"

6Seat map (coach C)
grid "Coach C", 32 cells

h2 "Seat key"

banner<header> in body

navigation "Booking steps"

list, 4 items

link "Railmoor home"

3 steps. Step through to see what one change does to these components.

Forms: labels, groups and errors

Railmoor's passenger details, as the screen reader hears it

Each label is a <p> above its input, not a <label>. It looks identical, but the tree has no link between them, so the email box falls back to its placeholder and the date box to DD/MM/YYYY. The placeholder stays the name after typing, but its text vanishes from view, so sighted users lose the label too.

Labels as styled text. Form “Who is travelling?”. Fields: First name = “Amara”; Surname = “Okonjo”; Email for your tickets = “name@example.com” (focused); Date of birth = “DD/MM/YYYY” (Needed for the senior railcard discount); I need a wheelchair space: unchecked. Button: “Continue to seats”. Links: Why we ask for this. Note on email: textbox "name@example.com" (placeholder used as name) Note on dob: textbox "DD/MM/YYYY"

  1. Label, autocomplete and error link5Passenger details form
  2. Native checkbox: role and state free5Passenger details form
  3. button "Continue to seats"5Passenger details form

The passenger form's semantics

<form aria-labelledby="who-title" novalidate>
  <h1 id="who-title">Who is travelling?</h1>

  <fieldset>
    <legend>Passenger 1 (adult)</legend>

    <label for="first">First name</label>
    <input id="first" name="first" autocomplete="given-name" required>

    <label for="surname">Surname</label>
    <input id="surname" name="surname" autocomplete="family-name" required>

    <label for="email">Email for your tickets</label>
    <input id="email" name="email" type="email" autocomplete="email"
           required aria-describedby="email-error">
    <p id="email-error" class="field-error" hidden></p>

    <label for="dob">Date of birth</label>
    <input id="dob" name="dob" autocomplete="bday" inputmode="numeric"
           aria-describedby="dob-hint">
    <p id="dob-hint">Day, month and year, for example 27 03 1958.
       Needed for the senior railcard discount.</p>

    <input id="wheelchair" type="checkbox" name="wheelchair">
    <label for="wheelchair">I need a wheelchair space</label>
  </fieldset>

  <button type="submit">Continue to seats</button>
</form>
// Tie a server-side message to its field so it is heard, not only seen.
export function showFieldError(id: string, message: string | null) {
  const input = document.getElementById(id) as HTMLInputElement;
  const note = document.getElementById(`${id}-error`)!;
  if (message) {
    note.textContent = message;          // words, not a red border alone
    note.hidden = false;
    input.setAttribute('aria-invalid', 'true');
  } else {
    note.textContent = '';
    note.hidden = true;                  // emptied too: a hidden node is still read when aria-describedby points at it
    input.removeAttribute('aria-invalid');
  }
}

Form semantics, field by field

NeedUseWhyWCAG
A name for every field<label for>Visible, clickable, survives typing; placeholder is a hint only1.3.1, 3.3.2, 4.1.2
A name for a group<fieldset> + <legend>Radio sets and repeated passenger blocks: "Passenger 2, First name" instead of three identical "First name" fields1.3.1
A hint or formataria-describedbyRead after the name, kept short3.3.2
An error on one fieldaria-invalid="true" + aria-describedby (or aria-errormessage)The field is announced as invalid with its message; aria-errormessage applies only while aria-invalid is true3.3.1, 3.3.3
A summary of errorsheading or banner with links to fieldsOne place to hear every problem after submit (moving focus there is a keyboard topic)3.3.1
Known personal dataautocomplete="email", "given-name", "bday"Browsers fill it and assistive tools can recognise what the field is for1.3.5 (AA)

Text alternatives for Railmoor's images

ImageKindMarkup
Hero photo of a train in a valleyDecorative: adds mood, no informationalt=""
Route map Kellbridge Central → Ashcombe JunctionInformative: carries the stopsalt="Route map: Kellbridge Central to Ashcombe Junction, 4 stops" plus the stops as a list on the page
Arrows icon on the swap buttonFunctional: the only content of a controlaria-label on the button, icon aria-hidden="true"
Wheelchair symbol next to "I need a wheelchair space"Redundant: the text says the samearia-hidden="true" (or alt="")
Coach C floor plan with seat numbersComplex: a picture of interactive dataNot an image at all: a grid of real seat buttons (in practice, below)
Was this section helpful?

In practice.

Railmoor's booking flow, audited the way a screen reader meets it: what the tree says for each control, what is wrong, and the smallest fix. Then the two custom widgets, and how to bring all of this into an interview answer.

An audit of the booking flow

Ten findings on Railmoor's journey, from search to seats

WhereThe tree saysProblemFixWCAGRule engine finds it?
Search: swap stationsbutton ""Icon-only button with no name: heard as "button"aria-label="Swap From and To"; icon aria-hidden4.1.2Yes: a missing name is mechanical
Search: route mapimage "kb-ash-v2.svg"No alt, so some screen readers read the file namealt with the route and stop count; stops also as a list1.1.1Yes for a missing alt
Results: train cardsheading level 1, then level 4Card titles are h4 under the page h1: levels 2 and 3 skippedh2 per train card1.3.1Yes: heading order is a structural check
Passengers: date of birthtextbox "DD/MM/YYYY"Placeholder used as the name; its text vanishes from view once typing starts<label for="dob">Date of birth</label>, format as a hint3.3.2, 4.1.2Partly: a name exists, so many rules pass it
Passengers: email errortextbox "Email for your tickets"Red text under the field is not linked; the field is not marked invalidaria-invalid plus aria-describedby to the message3.3.1No: the message exists, the missing link needs a human
Passengers: Continuegeneric, text "Continue to seats"A div with onclick: no role, no Tab stop<button type="submit">4.1.2, 2.1.1Rarely: nothing marks a click handler in markup
Seats: each seatgridcell "3D"Taken and free seats differ only by colour; name has no position or statusName "Seat 3D, window, available"; aria-disabled on taken seats1.3.1, 1.4.1No: the name is present, its meaning needs judgement
Seats: chosen seatgridcell "Seat 3D…" (no state)Choosing a seat changes the colour only; no selected statearia-selected="true" on the chosen cell4.1.2No
Seats: Review bookingbutton "Next"aria-label="Next" overrides the visible text Review booking; voice users saying the visible words miss itDelete the aria-label2.5.3Sometimes: label-in-name checks exist but are heuristic
Delay banner closebutton "Close" inside aria-hiddenClose button is focusable inside an aria-hidden wrapper: a nameless Tab stopRemove aria-hidden from the banner4.1.2Yes: focusable content inside aria-hidden

Four of the ten are mechanical: an attribute is missing or a rule about nesting is broken, so a rule engine in CI catches them every time. The rest need someone to decide whether a name is meaningful, whether a colour carries information, or whether a visible message is actually wired to its field. That split is typical, and it is why the trade-offs section below treats automated checks as a floor rather than a verdict.

The seat map: a grid that says what each seat is

Coach C, row 3, before and after

<div class="coach">
  <div class="seat-row">
    <span class="seat seat--taken">3A</span>
    <span class="seat seat--free" onclick="pick('3B')">3B</span>
    <span class="aisle"></span>
    <span class="seat seat--free" onclick="pick('3C')">3C</span>
    <span class="seat seat--chosen" onclick="pick('3D')">3D</span>
  </div>
  …
</div>
<h2 id="coach-c">Coach C</h2>
<div role="grid" aria-labelledby="coach-c">
  <div role="row">
    <button role="gridcell" aria-disabled="true" tabindex="-1"
            aria-label="Seat 3A, window, taken">3A</button>
    <button role="gridcell" aria-selected="false" tabindex="-1"
            aria-label="Seat 3B, aisle, available">3B</button>
    <button role="gridcell" aria-selected="false" tabindex="-1"
            aria-label="Seat 3C, aisle, available">3C</button>
    <button role="gridcell" aria-selected="true" tabindex="0"
            aria-label="Seat 3D, window, available">3D</button>
  </div>
  …
</div>
<!-- Which seat holds the single tab stop, and the arrow keys,
     are covered in Keyboard access and focus management. -->
grid "Coach C"            (8 rows, all in the DOM)
  row                       (row 3)
    gridcell "Seat 3A, window, taken"       disabled
    gridcell "Seat 3B, aisle, available"    selected=false
    gridcell "Seat 3C, aisle, available"    selected=false
    gridcell "Seat 3D, window, available"   selected=true  focusable

Choosing seat 3D with a screen reader

Choosing seat 3D with a screen reader, as an ordered list of steps:
Choosing seat 3D with a screen reader9 steps between Screen reader, Platform accessibility API, Browser accessibility tree, Seat map (coach C). The steps are listed as text after the diagram.Seat map (coach C)Browser accessibility treePlatform accessibility APIScreen readerspeaks it; the traveller presses Spacesets aria-selected="true" on 3D, false on the old seat"selected"focus moved: which node?1gridcell "Seat 3D, window, available", not selected, row 3 of 82action: press3click on button 3D4attribute changed5state-change event: selected6
  1. Screen reader → Platform accessibility API: focus moved: which node?
  2. Browser accessibility tree → Screen reader (reply): gridcell "Seat 3D, window, available", not selected, row 3 of 8
  3. Note over Screen reader: speaks it; the traveller presses Space
  4. Screen reader → Platform accessibility API: action: press
  5. Platform accessibility API → Seat map (coach C): click on button 3D
  6. Note over Seat map (coach C): sets aria-selected="true" on 3D, false on the old seat
  7. Seat map (coach C) → Browser accessibility tree: attribute changed
  8. Browser accessibility tree → Screen reader (reply): state-change event: selected
  9. Note over Screen reader: "selected"

The station field: what the combobox must say

Railmoor serves about 400 stations, so From and To are text boxes that suggest stations as the traveller types. HTML has no element for that, so this is where ARIA earns its place, following the combobox pattern in the ARIA Authoring Practices Guide. The semantic part is small but every piece must stay in step with the screen: the input is a combobox, it says whether its popup is open, it points at the listbox it controls, and it names the option currently highlighted. Which keys move the highlight is in Keyboard access and focus management; dropping stale responses while the traveller types is in the search-with-autocomplete flow.

From, while the traveller has typed "ash"

<label for="from">From</label>
<input id="from" type="text" role="combobox"
       aria-autocomplete="list"
       aria-expanded="true"
       aria-controls="from-stations"
       aria-activedescendant="from-opt-2"
       aria-describedby="from-count">
<p id="from-count" class="visually-hidden">3 stations match</p>

<ul id="from-stations" role="listbox" aria-label="Stations">
  <li id="from-opt-1" role="option" aria-selected="false">Ashcombe Junction</li>
  <li id="from-opt-2" role="option" aria-selected="true">Ashford Lanes</li>
  <li id="from-opt-3" role="option" aria-selected="false">Rashleigh Halt</li>
</ul>
combobox "From" expanded=true autocomplete=list value="ash"
    controls → listbox "Stations"
    activedescendant → option "Ashford Lanes"
listbox "Stations"
  option "Ashcombe Junction"  selected=false  (1 of 3)
  option "Ashford Lanes"      selected=true   (2 of 3)
  option "Rashleigh Halt"     selected=false  (3 of 3)

Every attribute is a fact that can go stale

AttributeMust equalIf it drifts
aria-expandedWhether the list is on screenThe list is open but announced collapsed, so the user never looks for it
aria-controlsThe id of the listboxA renamed id points at nothing; some screen readers lose the link to the list
aria-activedescendantThe id of the highlighted option, or absentArrowing moves the highlight but the screen reader keeps reading the old station
aria-selected on optionsThe highlighted option onlyTwo options claim to be selected after a re-render

What does the screen reader say here?

Q1
PassengersTab reaches the wheelchair checkbox, written as <input type="checkbox"> with a <label for>.
Roughly: "I need a wheelchair space, checkbox, not checked." Name from the label, role and state from the element. Exact wording differs between screen readers; the parts and their order do not.
Q2
PassengersThe same control built as <div class="tick"> with a click handler and the text beside it.
Nothing on Tab, because it is not focusable. Reading line by line, only the text "I need a wheelchair space" is heard, with no hint that it can be ticked or whether it is.
Q3
SeatsFocus lands on the chosen seat in the fixed grid.
"Seat 3D, window, available, selected", often followed by the row and column position the grid exposes.
Q4
SearchThe From list is open with Ashcombe Junction highlighted, and the traveller presses Down once.
"Ashford Lanes, 2 of 3": the option named by aria-activedescendant, with its position in the listbox. Focus never left the input, so typing still works.

Bringing it into a front-end system design answer

Moves that show you design for the tree, not just the pixels

State the requirement early: WCAG 2.2 AA, named in the non-functional requirements next to performance.
When you sketch a component, name its role, its name source and its states.
Say native first, and say why you leave it.
Put semantic state in the component's API, not in its CSS.
Mention landmarks and headings when you outline the page layout.
Close with how you would verify it.

Where the system flows use this

FlowWidgetThe semantics to name
Pinterest: search with autocompleteSearch suggestionscombobox + listbox, aria-expanded, aria-activedescendant
Instagram: email and password loginLogin formlabel per field, autocomplete tokens, aria-invalid + describedby
Amazon: checkout and paymentAddress and card forms, error summaryfieldset/legend, error links, named regions
Airbnb: date range pickerCalendargrid of days with full-date names, selected state
Notion: page treeSidebar treetree, treeitem, aria-expanded, aria-level
Was this section helpful?

Trade-offs.

Native elements are cheap and correct but limited in look and behaviour; custom widgets are flexible but every role, state and key becomes your code to keep true. And no single test tells you the tree is right.

01
Picking one of about 400 stations
Chosen:Text input with role=combobox and a filtered listbox (APG pattern)
  • Pro:Filtering by typing suits a list of 400
  • Pro:Can show recent stations, codes and "did you mean"
  • Pro:A well-documented pattern with known screen reader support
Downside we accept:
  • Con:Roles, states and the keyboard contract are all Railmoor's code
  • Con:Needs testing in every screen reader and browser pair Railmoor supports
Ruled out:Native <select> with 400 options

Scrolling 400 entries is slow for everyone; No filtering, no recent stations, no secondary text

Ruled out:<input list> with a <datalist>

Matching rules and presentation differ by browser; Cannot style the suggestions or add station details

Ruled out:Customizable select (appearance: base-select)

Ships from Chrome 135 (April 2025): the kiosks' frozen Chromium 128 does not have it; Still a select: no type-to-filter for 400 items

02
Choosing one of six railcard types
Chosen:Native <select>, styled within its limits
  • Pro:Zero ARIA to keep in sync
  • Pro:Works the same on the old kiosk browsers
  • Pro:Keyboard and screen reader behaviour users already know
Downside we accept:
  • Con:Limited styling of the open list in older browsers
Ruled out:Custom listbox (div role="listbox" with role="option")

About a dozen attributes and key handlers to get right; Every redesign risks breaking them; More to test for six static choices

How much a page full of barriers each tool caught

How much a page full of barriers each tool caughtEven all ten automated tools together missed 29% of 143 seeded barriers, and the best single tool caught only 41%.020%40%60%80%100%Best single toolWeakest single toolAll ten combinedFound by no tool41%17%71%29%ResultShare of the 143 barriers (%)How much a page full of barriers each tool caughtEven all ten automated tools together missed 29% of 143 seeded barriers, and the best single tool caught only 41%.020%40%60%80%100%Best single toolWeakest single to…Weakest single toolAll ten combinedFound by no tool41%17%71%29%ResultShare of the 143 barriers (%)
GOV.UK accessibility team, 2017: one test page with 143 deliberate failures in 19 categories, run through 10 automated tools (the best score counts prompts for a manual check). The tools are older than today's, but the gap between what rules can test and what needs judgement has not closed.
Data
Result% of barriers
Best single tool41
Weakest single tool17
All ten combined71
Found by no tool29

A testing stack for semantics, cheapest first

LayerCatchesMisses
Lint rules in the editor (JSX accessibility plugins)Missing alt, onClick on a div without a role, invalid ARIA attributes, while typingAnything decided at runtime
Rule engine in CI on rendered pagesMissing names, broken references, heading order, ARIA on the wrong elementWhether a name, alt or heading is meaningful; colour-only meaning
Component tests that query by role and nameA control that lost its role or name in a refactor; states after an interactionHow real screen readers phrase and navigate it
DevTools accessibility treeThe computed name and the source that won, when a name surprises youNothing automatically; it is an inspection tool
Manual screen reader passes (NVDA or JAWS on Windows, VoiceOver on macOS and iOS, TalkBack on Android)Wrong reading order, noisy or missing announcements, patterns that technically pass but confuseCosts time per release; needs practice to judge
Sessions with disabled usersWhether the journey actually works for peopleSlowest and most expensive; do it for the key flows

A component test that fails when the tree breaks

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PassengerForm } from './PassengerForm';

test('the email error is announced with the field', async () => {
  render(<PassengerForm />);
  const email = screen.getByRole('textbox', { name: 'Email for your tickets' });
  await userEvent.type(email, 'amara@');
  await userEvent.click(screen.getByRole('button', { name: 'Continue to seats' }));

  expect(email).toHaveAttribute('aria-invalid', 'true');
  expect(email).toHaveAccessibleDescription(
    'Enter an email address like name@example.com',
  );
});
// If Continue becomes a div again, getByRole('button') throws and the test fails.

How semantics drift after launch

FailureImpactDetectionMitigationMeanwhile
aria-expanded or aria-activedescendant not updated after a refactor7Station comboboxThe list is open but announced as collapsed, or arrowing reads the wrong stationA component test that asserts the attributes after each key; manual pass on the search pageDerive every ARIA attribute from the same state that drives the visualsSighted keyboard users are fine; screen reader users type the full station name blind
A redesign changes seat colours and drops the text status6Seat map (coach C)Taken and free seats sound identical; travellers pick seats that cannot be bookedReview checklist for colour-only meaning; tests on the seat nameKeep status in the name and aria-disabled, with the colour as decorationBooking fails at the payment step instead of at the seat
A design-system Input wrapper stops forwarding id, so labels no longer point at inputs5Passenger details formEvery field falls back to its placeholder or to no namegetByRole with name fails across the suite; the CI rule engine flags unlabelled fieldsThe Input component owns the label-to-input link and generates the id itselfMouse users see no change at all, so nobody files a bug
An aria-label added for tracking or testing overrides the visible text1Railmoor's HTML and ARIAScreen readers hear a different name from the one on screen; voice commands miss itLabel-in-name checks; reviewing aria-label in code reviewPrefer visible text; use data attributes for tests and analytics, not aria-labelThe control still works by keyboard and mouse
This topic
Role, name, state and value, and how names are computed
Landmarks, headings, labels, groups and error associations
Native elements versus ARIA, and the rules of ARIA
WCAG's POUR principles and conformance levels
Elsewhere
Tab order, roving tabindex, focus in dialogsKeyboard access and focus management
Live regions, contrast, reduced motion, target sizeAnnouncing changes and respecting preferences
Old kiosk browsers and feature detectionBrowser and device support
Right-to-left text and the lang attribute's effect on layoutRight-to-left and text layout
Racing suggestion requests in a comboboxPinterest, search with autocomplete
Was this section helpful?
Builds on this
Keyboard access and focus management
Read next