// 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.

INTERMEDIATE · 16 MIN READ · ACCESSIBILITY

§ 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.

Accessible name computation order Four priority tiers for how a control's accessible name is computed — aria-labelledby, then aria-label, then native labelling, then the title attribute as a last resort — next to a before-and-after example of the same icon button with and without a name. FIRST MATCH WINS ↓ 1 · aria-labelledby points to text elsewhere on the page 2 · aria-label a literal string set on the element itself 3 · native labelling label-for, alt text, visible text content 4 · title attribute last resort — inconsistent, easy to miss SAME ICON, TWO NAMES icon button name: "" — announced only "button" icon button aria-label="Search" name: "Search" — announced correctly
FIG. 01 — Accessible name computation order: the first source present wins, and an icon with no label announces nothing at all.

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.

DOM order versus a modal's focus trap Left: three boxes displayed in one visual order while their underlying DOM and Tab order stay fixed, producing a mismatch. Right: a modal dialog that traps Tab focus among its own three controls and returns focus to the trigger button once it closes. VISUAL ORDER ≠ DOM ORDER displayed left → right, via CSS order B C A 1 2 3 Tab still visits A → B → C, the DOM order — crossing back over what's on screen FOCUS TRAP INSIDE A DIALOG role="dialog" aria-modal="true" Field Cancel Save Tab wraps: Field → Save → Cancel → Field trigger button Esc / close → focus returns here
FIG. 02 — Left: CSS reordered three boxes visually while their DOM order stayed fixed, so Tab crosses back over the layout. Right: a well-behaved dialog traps Tab among its own controls and hands focus back to the button that opened it.

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-label or visually-hidden text the moment an icon stands in for a word.

  • 02

    outline: none with no :focus-visible replacement. 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

/ rules_of_thumb
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.

ACCESSIBILITY ARIA KEYBOARD NAV SEMANTIC HTML WCAG