Accessibility & ARIA

Close your eyes and try to use your app. No screen — just a keyboard and a voice reading the page aloud, one element at a time. Can you find the search box? Does a toast notification ever get announced, or does it flash and vanish in silence? When a menu opens, does Tab land where you expect, or fall through into the page behind it?

That is roughly the experience of the millions of people who reach your site through a screen reader, a switch device, voice control, or just the keyboard alone. None of them see your CSS. They read a parallel structure the browser builds from your markup: the accessibility tree. Get that tree right and everything downstream works. Get it wrong and the prettiest UI in the world is a locked door.

This lesson is about building that tree deliberately — with semantic HTML first, ARIA only where HTML runs out, and JavaScript to manage the parts HTML can’t: focus, announcements, and honoring what the user asked their operating system for.

The accessibility tree

The browser parses your HTML into the DOM. From the DOM it derives a second, smaller tree — the accessibility tree — and exposes it to the operating system’s accessibility APIs. A screen reader walks that tree, not your DOM. Each node it keeps is boiled down to a handful of facts: a role (what kind of thing is this?), an accessible name (what do we call it?), a state/value (is it checked, expanded, disabled?), and sometimes a description.

Nodes that carry no semantics — a plain <div> used only for layout — get collapsed away or flattened. Nodes that do carry meaning become entries the user can navigate to.

DOMAccessibility tree<div class=“card”><button aria-pressed=“true”>“Mute”<span class=“spacer”>computerole:buttonname:“Mute”state:pressedthe div & spacer span are pruned —no role, no name, nothing to announce
The same DOM node produces one accessibility-tree entry, reduced to role, name, and state. Layout-only wrappers are pruned.

You can see this tree yourself. In Chrome or Edge DevTools, open the Elements panel, pick a node, and switch to the Accessibility tab — it shows the computed name, role, and the source that produced the name. Firefox has an Accessibility panel that renders the whole tree. Start every accessibility investigation there, because it tells you what the user actually receives, not what you think your markup says.

Semantic HTML first

Here is the single most important rule, and it is worth memorizing:

A native <button> is not just a styled box. It is focusable, it is in the tab order, it fires click on both Enter and Space, it exposes the button role, and it takes its name from its text content — all for free. Rebuild it from a <div> and you inherit none of that.

<div
  class="btn"
  role="button"
  tabindex="0"
  onclick="save()"
  onkeydown="if(event.key==='Enter'||event.key===' ')save()"
>
  Save
</div>

<button class="btn" onclick="save()">Save</button>

The second version is shorter, correct on the first try, and keeps working when the platform adds new behavior. The <div> version forgot to handle Space scrolling the page, forgot type="button" semantics, and will drift out of sync the moment someone tweaks it.

Try it without touching your mouse. Click into the demo, then press Tab and Enter. Focus lands on the real <button> and Enter activates it — the bare <div> below it never even receives focus, so the keyboard can’t reach it at all.

interactiveKeyboard reaches a real button, not a bare div

Semantic elements each seed the accessibility tree with a role: <nav> becomes navigation, <main> becomes main, <h1><h6> become headings with levels, <a href> becomes a link, <input type="checkbox"> becomes a checkbox with a real checked state. Screen reader users navigate by these — jumping heading to heading, listing all landmarks, pulling up every form control. A page built from <div> soup offers them nothing to jump between.

Roles, states, and properties

When HTML genuinely runs out — a tab panel, a combobox, a tree grid, things with no native element — ARIA is how you describe them. ARIA is a vocabulary of attributes that write directly into the accessibility tree. Critically, ARIA changes only the tree. It adds no behavior, no focus, no keyboard handling, no styling. If you write role="button" you still have to make it focusable and wire up the keys yourself. ARIA is a set of labels, not a set of components.

The vocabulary has three parts:

  • Roles — what the element is: role="tab", role="dialog", role="alert", role="progressbar".
  • States — dynamic, changeable conditions: aria-checked, aria-expanded, aria-selected, aria-disabled, aria-busy. These flip as the user interacts.
  • Properties — more static characteristics: aria-label, aria-labelledby, aria-describedby, aria-controls, aria-haspopup.

The line between state and property is mostly a matter of how often the value changes; in practice they are all just attributes you set and update.

<div role=“tab” aria-selected=“true” aria-controls=“p1”>ROLEtabwhat it isSTATEaria-selectedchanges as you clickPROPERTYaria-controlspoints at panel p1
Anatomy of an accessible custom widget: a role names the pattern, properties wire up relationships, states track live conditions.

Two ARIA rules that save you from the most common mistakes:

  1. Don’t override native roles. <button role="heading"> is almost always a bug. If you find yourself doing it, you probably picked the wrong element.
  2. Keep required states in sync. If you set aria-expanded="false" on a disclosure button, you must flip it to "true" in JavaScript when it opens. A stale ARIA state lies to the user, which is worse than saying nothing.

Accessible names

Every interactive element needs an accessible name — the string the screen reader speaks when focus lands on it. “Button” is useless; “Delete invoice” is a name. The browser computes this name from several possible sources, and there is a strict precedence order. Higher on this list wins:

1. aria-labelledbyreferences another element’s text — highest priority2. aria-labela literal string you write on the element3. native: <label>, alt, <caption>, text contentthe HTML-native association4. title attributefragile fallback — avoid relying on itFirst non-empty source wins; the rest are ignored.So aria-labelledby silently overrides a visible <label> — a frequent surprise.
Accessible name precedence. The browser stops at the first source that yields a non-empty string.

In order of preference for how you should name things:

<label for="email">Email</label>
<input id="email" type="email" />

<button aria-label="Close dialog">✕</button>

<h2 id="billing-title">Billing address</h2>
<section role="group" aria-labelledby="billing-title">…</section>

One trap worth calling out: aria-labelledby beats everything, including a control’s own visible text. If a button reads “Save” on screen but carries aria-labelledby="tooltip", the screen reader announces the tooltip’s text, not “Save”. When the spoken name and the visible name diverge, voice-control users who say “click Save” are stranded. Keep them aligned.

Live regions: announcing change

Screen readers speak the element under focus. But a lot happens away from focus — a “Saved” toast, a search result count updating as you type, a validation error appearing below a field, a chat message arriving. By default the screen reader knows nothing about these. A live region is how you say: “when the contents of this element change, announce the change.”

You mark a region with aria-live, or with a role that implies it:

  • aria-live="polite" — announce at the next natural pause. This is what you want almost always.
  • aria-live="assertive" — interrupt whatever is being spoken right now. Reserve this for genuinely urgent, time-sensitive things (a session about to expire, a payment failure). Overuse is hostile.
  • role="status" implies polite; role="alert" implies assertive; role="log" is for append-only histories like a chat.
your JS writesstatus.textContent= “Saved”aria-live=“polite”browser flags amutation in this regionscreen reader queuesand speaks at thenext pause🔊 “Saved”Key rule: the empty region mustalready exist in the DOM beforeyou write into it.
A polite live region: content changes, the accessibility API notices the mutation, the screen reader queues it and speaks it at the next pause.

The pattern that actually works in practice: put an empty live region in your markup at load time, then change its text later. Screen readers watch a region for mutations; if you inject the whole region and its content in one shot, many of them miss it.

<div id="status" role="status" aria-live="polite" class="sr-only"></div>
// Later, when something happens:
function announce(message) {
  const region = document.getElementById('status');
  // Clear first so identical consecutive messages still re-announce.
  region.textContent = '';
  requestAnimationFrame(() => { region.textContent = message; });
}

announce('Draft saved');

That sr-only class visually hides the region while keeping it in the accessibility tree — the standard “clip it to a 1px box, don’t use display:none” trick:

.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

You can’t hear a screen reader on this page, so the demo below makes the queue visible: the role="status" region updates on each action, and every message it emits is mirrored into a “spoken” log the way a screen reader would voice it. Notice the clear-then-write step — click Save draft twice and the identical message still re-announces.

interactiveA polite live region announcing changes

Keyboard access

Everything a mouse can do, a keyboard must be able to do too. That is not a nice-to-have; it is the baseline for switch users, screen reader users, people with tremor or RSI, and power users. Native interactive elements are keyboard-operable out of the box. The moment you build a custom widget, keyboard support becomes your job.

Focus order and tabindex

Tab moves focus forward through focusable elements; Shift + Tab moves back. The order follows DOM order — which is one more reason to keep your DOM in the order things actually read, and to reorder visually with CSS (flexbox order, grid placement) rather than by scrambling the source.

The tabindex attribute has exactly three meaningful cases:

tabindex=“0”in tab order, at itsnatural DOM positiontabindex=“-1”NOT tabbable, butfocusable via .focus()tabindex=“5”jumps the queue —avoid, breaks ordertabindex=“0”make a custom widget (div, li) tabbable.tabindex=“-1”target for programmatic focus: headings, roving items, dialogs.tabindex=“2+”forces order globally; one stray value derails the whole page.
The three tabindex values. Positive values are an anti-pattern that fights the natural DOM order.

Roving tabindex for composite widgets

Here is a subtlety. A toolbar with twelve buttons, or a listbox with fifty options, should not be twelve or fifty Tab stops. That would make a keyboard user tab forever to get past it. The convention — spelled out in the ARIA Authoring Practices Guide — is that a composite widget is a single tab stop. You Tab into it once, then move within it using the arrow keys.

The technique that implements this is roving tabindex: exactly one child has tabindex="0" (the current item), every other child has tabindex="-1". Arrow keys move the 0 from one child to the next and call .focus() on the new current item.

role=“toolbar” — one Tab stop, arrows move insideBoldtabindex=0-1Italictabindex=-1Linktabindex=-1Press → the 0 and the focus ring rove together to the next item.The other items become tabindex=-1, so Tab exits the whole toolbar.
Roving tabindex in a toolbar: only the active item is tabbable (0); arrows move both the focus and the single 0 to the next item.
const toolbar = document.querySelector('[role="toolbar"]');
const items = [...toolbar.querySelectorAll('button')];

// Start: first item tabbable, rest reachable only programmatically.
items.forEach((el, i) => (el.tabIndex = i === 0 ? 0 : -1));

toolbar.addEventListener('keydown', (e) => {
  const current = items.indexOf(document.activeElement);
  if (current === -1) return;

  let next = current;
  if (e.key === 'ArrowRight') next = (current + 1) % items.length;
  else if (e.key === 'ArrowLeft') next = (current - 1 + items.length) % items.length;
  else if (e.key === 'Home') next = 0;
  else if (e.key === 'End') next = items.length - 1;
  else return;

  e.preventDefault();
  items[current].tabIndex = -1;   // old item leaves the tab order
  items[next].tabIndex = 0;       // new item joins it
  items[next].focus();            // and takes focus now
});
Pressing → once in the toolbar
1/6
Variables
Bold.tabIndex=0
Italic.tabIndex=-1
Link.tabIndex=-1
Initial roving state: only Bold is tabbable.

The walkthrough traces one arrow press; the demo below is the whole thing wired up and live. Click Bold, then drive it entirely with the keyboard: move within the toolbar, Home / End jump to the ends, and a single Tab leaves the whole group and lands on the control after it.

interactiveRoving tabindex: one tab stop, arrows move inside

Roving tabindex is one of two blessed patterns; the other is aria-activedescendant, where focus stays on the container and an ARIA property points at the “virtually focused” child. Roving is easier to get right for most widgets. Either way, the widget presents one tab stop.

Trapping focus in a modal

When a modal opens, focus must move into it, stay inside while it’s open, and return to the triggering element when it closes. Everything behind it should be unreachable — by Tab and by screen reader.

You could hand-write a focus trap (listen for Tab on the last element and wrap to the first). But the platform now does this for you. The native <dialog> element opened with showModal() traps Tab, renders everything else inert, closes on Esc, and restores focus on close — all built in and Baseline across current browsers.

const dialog = document.querySelector('dialog');

openBtn.addEventListener('click', () => {
  dialog.showModal();     // focus moves in; background becomes inert
});

// On close, the browser returns focus to openBtn automatically.
dialog.addEventListener('close', () => {
  // run any cleanup here
});

Open the dialog below and try to Tab your way out of it — you can’t. Focus cycles among the dialog’s own controls, Esc closes it, and when it closes, focus snaps back to the button that opened it. All of that is the platform’s doing; there is no focus-trap code here.

interactiveNative <dialog> traps focus and restores it

If you must build a modal from a <div> (legacy design system, say), the inert attribute is your tool for the “make the rest of the page unreachable” half. Set inert on everything outside the modal and those subtrees drop out of the tab order and the accessibility tree entirely. inert is also Baseline in current browsers.

// Roll-your-own: neutralize the rest of the page while the modal is open.
document.querySelectorAll('body > *:not(.modal)').forEach(el => (el.inert = true));
// ...and clear it (el.inert = false) when the modal closes.

Managing focus on route change

In a single-page app, clicking a link swaps the view without a full page load. To a mouse user nothing looks wrong. To a screen reader user, nothing was announced and focus is stranded on the now-gone link — they have no idea the page changed. You have to manage focus manually.

The common fix: after the new view renders, move focus to its heading (or a top-of-content element), and announce the new page title through a live region.

function onRouteChange(newTitle) {
  document.title = newTitle;

  const heading = document.querySelector('main h1');
  if (heading) {
    heading.setAttribute('tabindex', '-1'); // make it programmatically focusable
    heading.focus();                        // move the user to the new content
  }

  announce(`${newTitle} page loaded`);       // reuse the live region from earlier
}

Honor the user’s preferences

People configure their operating systems for their needs. Your job is to read those signals and adapt. CSS media features expose several of them.

prefers-reduced-motion is the big one — set by users who get motion sickness, vestibular disorders, or migraines from animation. It is widely supported (Baseline) and you should honor it everywhere you animate. Default to motion being off and only add it when the user hasn’t asked to reduce it:

/* Static by default; opt in to motion. */
.card {
  transition: none;
}
@media (prefers-reduced-motion: no-preference) {
  .card {
    transition: transform 0.25s ease;
  }
}

You can read the same signal in JavaScript, which matters for animations you drive with the Web Animations API or requestAnimationFrame:

const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');

function animateIn(el) {
  if (reduce.matches) {
    el.style.opacity = '1';   // jump straight to the end state
    return;
  }
  el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 250 });
}

// React to changes made while the page is open:
reduce.addEventListener('change', updateMotionSettings);

There is also Windows High Contrast / forced-colors mode, where the OS overrides your palette entirely. Detect it with the forced-colors: active media query, and make sure you’re using system color keywords (CanvasText, ButtonText, Highlight) rather than fighting the override. The main rule: don’t convey meaning through color alone, and never display:none your focus outline in forced-colors mode.

A ten-minute audit

You do not need a lab to catch the majority of issues. Three passes, done regularly, find most of them:

  1. Unplug the mouse. Tab through the whole page. Can you reach every control? Is the focus ring always visible? Does the order make sense? Can you open and, crucially, close every menu and modal with the keyboard, and does focus return sensibly? This one pass catches an enormous share of real bugs.
  2. Turn on a screen reader. VoiceOver ships with macOS (Cmd + F5) and iOS; Narrator with Windows; TalkBack with Android; NVDA is a free download for Windows. Tab through and listen. Does every control announce a sensible name and role? Do dynamic updates get spoken? You will hear the “button, button, button” of missing names immediately.
  3. Check contrast and structure. Run an automated pass — the Accessibility checks in Chrome/Edge DevTools (Lighthouse), or the axe DevTools extension — for color-contrast failures, missing names, and broken heading order. Automated tools catch maybe a third of issues, so treat them as a floor, not a finish line.

Summary

  • Assistive tech reads the accessibility tree, not your CSS. Every meaningful node reduces to a role, an accessible name, and state. Inspect it in DevTools.
  • Semantic HTML first. Native elements bring role, focus, and keyboard behavior for free. Reach for ARIA only when HTML has no element for the pattern — “no ARIA is better than bad ARIA.”
  • ARIA is labels, not behavior. Roles say what a thing is; states and properties track and connect it. If you add a role, you own the focus and keyboard wiring too — and you must keep states in sync.
  • Every control needs an accessible name. Prefer a visible <label> or aria-labelledby; keep the spoken name aligned with the visible one. aria-labelledby silently outranks everything.
  • Live regions announce off-focus changes. Keep an empty polite region mounted from the start and write text into it; save assertive for the truly urgent.
  • Keyboard is the baseline. tabindex="0" to add, -1 for programmatic focus, never positive values. One tab stop per composite widget via roving tabindex. Trap focus in modals — <dialog>’s showModal() and the inert attribute do it for you. Manage focus on SPA route changes.
  • Honor OS preferences — especially prefers-reduced-motion, which is Baseline and non-negotiable. Default to no motion and opt in.
  • Audit continuously: keyboard-only pass, screen reader pass, automated contrast/structure check. The first two find what tooling can’t.