// accessibility · structural_model
Accessibility Is a Structural Decision, Not a Final Pass
Most teams learn accessibility as a checklist run at the end of a
sprint — alt text, a contrast pass, an aria-label
sprinkled onto anything that complains in an audit. This is a
structural read of what that checklist is actually testing
underneath: how markup produces an accessibility tree, how a
control's name gets computed, and why almost none of it can be
patched on after the fact.
§ 01
The Structural Model
A screen reader doesn't read your page. It reads a second,
parallel data structure the browser builds from your markup called
the accessibility tree — a filtered,
semantic-only view of the DOM where purely visual containers like
generic divs and spans are mostly
flattened away, and elements with actual meaning survive as a node
with three things attached: a role (what kind of
thing this is), a name (what to call it out
loud), and a set of states (its current condition
— expanded, checked, disabled, current). Every other assistive
technology — switch access, voice control, braille displays —
reads from that same tree, not from pixels.
This is the fact that reframes everything else in this guide: ARIA
attributes can only edit labels and metadata on a tree whose
structure was already decided by your HTML. They cannot
invent a heading that isn't there, cannot turn a flat sequence of
clickable divs into a real list, and cannot
retroactively give a page landmarks it never had. Structure comes
from markup, written once, at authoring time. Everything ARIA can
do is layered on top of whatever structure already exists — which
is exactly why "run an audit, then patch the failures with ARIA"
produces pages that pass automated scans while still being
confusing or unusable with a screen reader.
Treat every interactive piece of UI as three decisions made in this order, the same way a load-bearing wall gets decided before paint color: what role does this already have, or need declared; what name will assistive tech attach to it; and what states will need to update as the user interacts with it. Get those three right at the markup layer and most of what's normally called "accessibility work" turns out to already be done.
§ 02
Semantic HTML Is Free Accessibility You're Not Using
Native HTML elements ship with role, keyboard behavior, and often
state handling built in, at no cost. A
<button> is focusable, already has
role="button", and already responds to both Enter and
Space without a line of JavaScript. Rebuild that same button out
of a <div onclick> instead, and you now owe the
platform every piece of that for free: a
tabindex="0" so it's reachable, an explicit
role="button", and a keydown handler for
two different keys, because browsers don't auto-activate
a plain div on Enter or Space the way they do a real button. It's
not that this debt can't be repaid — it's that it's easy to
under-repay, and nothing visually signals that you have.
Landmark elements — <header>,
<nav>, <main>,
<aside>, <footer> — give
screen reader users a jump list roughly equivalent to what sighted
users get from peripheral vision scanning a page layout: "skip to
the nav," "skip to main," without reading everything in between.
It's one of the most common navigation methods screen reader users
rely on, and it's unavailable on a page built entirely from
generic containers.
Heading levels are a second jump list. Many screen reader users
routinely skim a page by pulling up its heading outline —
h1 through h6 — the same way a sighted
user's eye jumps between bold headlines on a page before deciding
what to actually read. Choosing a heading tag for its default font
size instead of its place in the outline, or skipping from an
h2 straight to an h4 because it "looked
right," breaks that outline for the people relying on it — even
though nothing about it looks wrong.
§ 03
The Accessible Name
Every node in the accessibility tree carries an accessible name — the string a screen reader actually announces for it — and that string is not always the text you can see. It's the output of a small, ordered computation, and the first source present wins outright, even overriding sources further down the list.
aria-labelledby is checked first: if it's present, it
wins completely, pulling its text from wherever it points — even
if that's nowhere near the control visually, and even if the
control also has visible text of its own that gets ignored. Next
comes aria-label, a literal string set directly on
the element. After that comes native labelling — a
<label for> association, an
alt attribute on an image, or the element's own
visible text content. Last, and unreliable enough to treat as a
fallback rather than a strategy, is the title
attribute — inconsistently announced across screen readers and
often invisible until a mouse hover that keyboard and touch users
never trigger.
Two failures come directly out of this order. The common one is an
icon-only button — a magnifying glass, a hamburger, a trash-can —
with no visible text and no aria-label, so its
computed name is an empty string. A screen reader announces it as
simply "button," giving no indication of what it does. The subtler
one is a mismatch between the visible label and the accessible
name: setting an aria-label that reads differently
from the text a sighted user sees breaks voice-control users, who
typically activate a control by speaking its
visible label — this exact mismatch has its own WCAG
success criterion (2.5.3, Label in Name) because it's common
enough to name directly.
§ 04
ARIA: Roles, States, and Properties
ARIA is a vocabulary of attributes that describe roles, states, and properties to the accessibility tree — nothing more. It changes no styling, adds no keyboard behavior, and triggers no JavaScript on its own; it is purely a labeling layer sitting on top of whatever the DOM and your event handlers already do. That single fact resolves most confusion about when to reach for it.
Three practical rules cover the majority of real cases. First, if
a native element already does the job, use it — recreating a
button out of a div plus
role="button" only rebuilds, by hand and imperfectly,
what <button> already gives for free. Second,
avoid overriding an element's built-in semantics unless there's
genuinely no other option — putting
role="presentation" on a data table strips its
structure from assistive tech entirely. Third, and most often
skipped: every interactive role needs both a real accessible name
and full keyboard operability. A role with no name is frequently
worse than no role at all, because it tells assistive technology
"this matters, pay attention," and then has nothing to say when it
does.
It helps to separate three things ARIA attributes describe.
Roles declare what an element fundamentally is —
role="dialog", role="tab",
role="alert" — and are generally set once and left
alone. States describe a condition that changes
as the user interacts — aria-expanded,
aria-checked, aria-selected,
aria-current — and must be kept in sync with the
actual UI on every change, not set once at load.
Properties describe more stable relationships
between elements — aria-describedby,
aria-controls, aria-labelledby — wiring
one element to another that the visual layout alone doesn't make
explicit.
aria-live regions announce content that changes
without a page reload — a save confirmation, a cart total, an
inline validation message. polite waits for the
screen reader to finish whatever it's currently saying before
announcing the update; assertive interrupts
immediately and should be reserved for things that genuinely can't
wait, like a failed submission. The common mistake runs in both
directions: marking too much of a page as live and drowning users
in announcements for routine UI churn, or never marking anything
live and leaving async updates — a cart count ticking up, a
spinner resolving — completely silent to anyone not looking
directly at that pixel.
§ 05
Focus: Order, Visibility, and Traps
Tab order follows DOM order, full stop — not the
order things appear on screen. Reorder elements visually with CSS
order, grid placement, or absolute positioning
without moving them in the underlying markup, and the Tab key
still walks the original DOM sequence, jumping across the screen
in a pattern that has nothing to do with what a sighted user just
read left to right. Nobody notices this in a mouse-only
walkthrough, because a mouse never has to follow Tab order — it's
exclusively a keyboard and screen-reader problem, which is exactly
why it survives so many rounds of visual QA untouched.
A second, related failure is removing the default focus outline —
outline: none — without shipping a replacement. That
outline is the only signal a keyboard user gets for where they
currently are on the page; strip it and every keyboard interaction
becomes a guessing game. The
:focus-visible pseudo-class exists precisely for
this: it lets you style focus differently for keyboard navigation
than for a mouse click, so the outline that looks unnecessary on a
clicked button still appears for the person who tabbed to it.
Skip links — a hidden "Skip to main content" link that becomes visible on focus — let keyboard and screen reader users bypass repeated navigation instead of tabbing through the same header on every single page. The common failure isn't a missing skip link; it's a skip link that's present but not actually the first focusable element, or that fails to move focus to the destination it points to.
That right-hand pattern is worth stating as a checklist on its own, because a modal is one of the highest-stakes focus interactions on most sites: on open, focus should move into the dialog, typically to its first focusable control or the dialog itself; while it's open, Tab and Shift+Tab should cycle only among the dialog's own controls, never leaking back to the page underneath; and on close — whether by Escape, a Cancel button, or a successful submit — focus should return to the exact element that opened it. That last step is the one most commonly skipped, and skipping it means a keyboard user closes a dialog and has no idea where they've landed on the page.
One more focus case that's easy to miss: single-page apps that swap content without a full page load give a screen reader no automatic signal that navigation happened, because there was no new page load to announce. Moving focus to the new view's heading — or firing a dedicated live-region announcement — on every route change is what replaces that missing signal; without it, a screen reader user can trigger a navigation and simply never learn that anything changed.
§ 06
Keyboard Patterns for Custom Widgets
For genuinely custom widgets — tabs, menus, comboboxes, sliders, listboxes — there's an established set of keyboard conventions (the WAI-ARIA Authoring Practices patterns) that exist so a tab list behaves the same way on your site as it does everywhere else a keyboard or screen reader user has already learned it. The baseline expectations are consistent across widgets: Enter or Space activates, Escape closes or cancels, arrow keys move between items inside a composite control, and Home/End jump to the first or last item.
A pattern called roving tabindex underlies most
composite widgets: only one item inside the group is ever a Tab
stop at a time, and arrow keys move that single stop between
siblings, updating each item's tabindex as focus
moves. That keeps the Tab key reserved for entering and leaving
the whole widget as one unit — a tab list with eight tabs should
cost the rest of the page exactly one Tab stop, not eight.
The failure worth calling out explicitly here connects straight
back to section four: a custom dropdown can carry every "correct"
ARIA role — combobox, listbox,
option — and still be completely inoperable by
keyboard, if the only code wired up is a mouse
onclick handler. It will look right in a visual
review and often pass an automated scan that only checks whether
the attributes are present, while being unusable for anyone who
can't click. ARIA describes intent; your event handlers are what
actually deliver it.
§ 07
Forms: Labels, Errors, and Announcements
Every input needs a programmatically associated label — a real
<label for="id">, or the input wrapped inside
one — not placeholder text standing in for it. Placeholder text
disappears the moment someone starts typing, isn't reliably
treated as a persistent label by every screen reader, and
typically ships with contrast too low to read comfortably by
default.
Related inputs need a group label, not just individual ones — wrap
a set of radio buttons, or the several fields of an address, in
<fieldset> with a
<legend> so assistive tech announces what the
group as a whole represents, rather than presenting four unrelated
fields with no shared context.
For validation errors, connect the error text to its field with
aria-describedby pointing at the error message's
id, and set aria-invalid="true" on the
input while the problem stands — that combination means a screen
reader announces both the field's name and its specific error the
moment it receives focus, instead of relying on red text a sighted
user has to spot visually. When errors surface asynchronously —
after a failed submit, or live as someone types — wrap the summary
or message in aria-live="polite", or
role="alert" for something urgent enough to
interrupt, so the update gets announced without the user having to
re-scan the whole form to discover it. The balance to hold is not
over-announcing: a live region that fires on every keystroke turns
a helpful signal into constant noise.
§ 08
Testing: Coverage vs. What It Misses
Automated scanners — axe, Lighthouse, WAVE — are genuinely good at what they check: missing alt text, insufficient color contrast, unlabeled form fields, invalid ARIA attribute values, duplicate IDs. Commonly cited industry estimates put automated coverage at roughly a third to half of WCAG's success criteria — a real floor worth having in CI, not a ceiling worth trusting on its own.
What a scanner structurally cannot judge: whether alt text is actually descriptive rather than a filename, whether reading order makes sense as a sequence rather than merely existing, whether a custom widget from section six is truly operable by keyboard rather than just correctly labeled, whether a live region announces at a sane frequency, or whether focus really returns to the right place once a dialog closes. All five of those require a human running through the interface, not a rule checking an attribute's presence.
A minimum manual pass covers most of that gap cheaply: unplug the mouse and complete your core flow using only Tab, Shift+Tab, Enter, Space, arrow keys, and Escape; spend five minutes on that same flow with a screen reader running — VoiceOver ships free on macOS, NVDA is free on Windows — rather than only skimming the homepage; and zoom the browser to 200% to confirm content reflows into a single column instead of clipping or forcing horizontal scroll. None of that replaces automated tooling — it catches the roughly half of real issues automated tooling was never built to see.
§ 09
Six Pitfalls That Look Like Edge Cases
-
01
Icon-only buttons with no accessible name. The single most common failure from section three — a search or close icon that's a real, focusable control announced as nothing but "button." Add
aria-labelor visually-hidden text the moment an icon stands in for a word. -
02
outline: nonewith no:focus-visiblereplacement. Removes the only signal a keyboard user has for their current position on the page. Replace it, never just delete it. -
03
Reordering visually with CSS without reordering the DOM. Creates the exact mismatch shown in figure two — the fix is moving the markup, not adding an ARIA attribute on top of it.
-
04
Correct roles, no behavior. A custom widget with every appropriate ARIA role wired up, and only a mouse click handler underneath it — inoperable by keyboard, invisible to an audit that only checks for attribute presence.
-
05
Modals that don't return focus on close. A keyboard user who opened a dialog loses their place in the page entirely once it closes without sending focus back to the trigger.
-
06
Ignoring
prefers-reduced-motion. Parallax, autoplaying carousels, and large animated transitions can cause real physical symptoms for people with vestibular disorders, not just mild annoyance. Wrap non-essential motion behind the media query and provide a still, static equivalent when it's set.
§ 10
Quick Reference
| Concept | What it controls | Rule of thumb |
|---|---|---|
| Accessible name | What's announced for a control | labelledby beats label beats native content |
| Role | What kind of control this is | Prefer the native element over role on a div |
| ARIA state | Current condition of a control | Update on every change, not just once at load |
| Focus order | Tab key sequence | Follows DOM order, never CSS visual order |
| :focus-visible | Visible focus indicator | Restyle it — never remove it outright |
| aria-live | Announces content that changes without reload | polite for routine updates, alert for urgent ones |
| Roving tabindex | Which item in a composite is Tab-reachable | One tab stop per widget; arrows move within it |
| aria-describedby | Extra description read after the name | Point it at error text tied to that exact field |
Almost everything in this guide is cheaper the earlier it's
decided. A heading level, a native <button>, a
label wired to its input — each one costs nothing extra when it's
the first draft of the markup, and costs a rebuild when it's
retrofitted after launch. Accessibility rewards being treated as a
structural decision made once, not a final pass run at the end.