← Back to the library
FrontendHow it works · Applied · 15 min

Web accessibility: the accessibility tree, ARIA, focus and testing

How does a screen reader or switch user actually experience your UI, and what does it take to build and verify an accessible interface?

Assistive technologies do not read your pixels or your DOM; they read the accessibility tree the browser derives from it. Native HTML gives that tree correct roles, names, states and keyboard behavior for free; ARIA only patches the tree, and every role you add is a promise to implement the behavior yourself.

THE MENTAL MODEL

Every element becomes, or does not become, a node in the accessibility tree with four things: a role (button, link, dialog), an accessible name (from content, a label, aria-labelledby or aria-label), states and properties (expanded, checked, invalid, disabled) and a place in the hierarchy. The operating system's accessibility API exposes that tree to screen readers, voice control and switch devices, which then drive the page through focus and keyboard events. So accessibility is three contracts: the tree must tell the truth about what is on screen, everything operable by pointer must be operable by keyboard with visible focus, and changes that happen without a focus move must be announced. Semantic HTML satisfies most of all three by default; custom widgets must rebuild each one by hand, which is why bad ARIA is worse than none.

HOW IT FITS TOGETHER

From markup to what a screen reader announcesCSS and ARIA both change the tree: display: none and aria-hidden remove nodes, a role replaces the native one. Nothing in ARIA adds behavior.
  1. DOM + CSSElements, attributes and computed style. display: none, visibility: hidden and the hidden attribute remove content from the tree; visually hidden CSS does not.
  2. Role mappingEach element maps to a role via HTML-AAM: <button> to button, <nav> to navigation, <div> to generic (often pruned). An explicit role attribute overrides it.
  3. Name and descriptionAccessible name computed in order: aria-labelledby, aria-label, native label (<label>, alt, caption, legend), then content for roles that allow it, then title.
  4. States and relationsNative state (disabled, checked, required, open) plus aria-expanded, aria-invalid, aria-describedby, aria-controls, aria-current.
  5. Accessibility treePruned, semantic tree. Inspect it in the DevTools Accessibility pane or the full-tree view in Chrome and Firefox.
  6. Platform APIUIA or IAccessible2 on Windows, NSAccessibility on macOS and iOS, ATK/AT-SPI on Linux, Android accessibility APIs.
  7. Assistive technologyNVDA, JAWS, VoiceOver, TalkBack, Narrator, voice control and switch access read the tree, listen for events and send focus and key input back.
Native element vs hand-built ARIA widgetA role is a promise. Everything in the right column is your code to write, test in several screen readers and maintain.
Native button / dialogdiv with ARIA role
Role and name in the treeAutomatic from element and contentRole via attribute; name must still be provided
Keyboard focusableYes, in tab orderOnly with tabindex="0"
Activation keysEnter and Space fire clickWrite keydown handlers for Enter and Space
Disabled statedisabled removes focus and clicksaria-disabled plus blocking handlers yourself
Form participationSubmits forms, works without JSNone
Modal: rest of page inertshowModal() makes outside content inertApply inert or aria-hidden to siblings yourself
Modal: Escape and focusEsc closes; focus moves into the dialogImplement Esc, initial focus, trap and return
Top layer and backdropRendered above all z-index, ::backdropManage z-index and overlay styling
High contrast / forced colorsSystem colors applied by the browserOften invisible borders; needs forced-colors CSS

KEY TERMS

Semantic HTML first
Use the element whose built-in role and behavior match: button for actions, a href for navigation, real form controls with label, headings in order, landmarks (header, nav, main, footer), lists and tables for lists and tables. One <main> and a logical heading outline give screen reader users the navigation that sighted users get from layout.
The rules of ARIA
Do not use ARIA if a native element or attribute does the job. Do not change native semantics unless you must. Every interactive ARIA control must be keyboard operable. Never put role="presentation" or aria-hidden="true" on a focusable element. Interactive elements need an accessible name. The ARIA Authoring Practices summarize it as "No ARIA is better than bad ARIA": a role is a promise of behavior.
Accessible names
The name is what is announced and what voice control users say. Visible text is the best source; for icon buttons use visually hidden text or aria-label. The name of an input should contain its visible label (WCAG 2.5.3). Placeholders and title are poor names. aria-describedby adds secondary information such as hints and errors after the name.
Focus management and keyboard patterns
Tab moves between widgets; arrow keys move within composite widgets (tabs, menus, listboxes, grids) using roving tabindex or aria-activedescendant. Focus must be visible (:focus-visible, at least 3:1 contrast) and never fully hidden behind sticky content (WCAG 2.2 Focus Not Obscured). Move focus deliberately when context changes: into a dialog, back to the trigger on close, to the new page heading after a client-side route change.
Live regions
Changes that do not move focus (toasts, search result counts, save status, async validation) are only announced through a live region: role="status" or aria-live="polite" for most updates, role="alert" for urgent ones. The region must already exist in the DOM before its content changes; injecting a region together with its text is often not announced.
Color contrast and WCAG 2.2 AA
Text needs 4.5:1 against its background, large text (24px, or about 18.7px bold) 3:1, and UI component boundaries, focus indicators and meaningful graphics 3:1 (1.4.11). Color must not be the only cue. WCAG 2.2 (W3C Recommendation, current edition December 2024) adds at levels A and AA: focus not obscured, target size of at least 24 by 24 CSS pixels or equivalent spacing, alternatives to dragging, consistent help, no redundant entry and accessible authentication; it removes 4.1.1 Parsing.
Testing and the law
Automated tools such as axe-core find a minority of issues (missing names, contrast, invalid ARIA); keyboard-only passes and screen reader testing (NVDA or JAWS with Chrome or Firefox, VoiceOver with Safari, TalkBack on Android) find the rest. The European Accessibility Act (Directive 2019/882) applies since 28 June 2025 to products placed on the EU market and to services such as e-commerce, banking, e-books, transport ticketing and communications; microenterprises providing services are exempt, and existing service contracts may continue for at most five years. Conformance is assessed in practice against EN 301 549: the harmonised V3.2.1 mirrors WCAG 2.1 AA, and V4.1.1 (published September 2026, not yet cited in the Official Journal) adopts WCAG 2.2 AA, so WCAG 2.2 AA is the sensible target.

IN YOUR STACK

HTML + JavaScript · <dialog> + invoker commands Client

<button commandfor="delete-dialog" command="show-modal">Delete project</button>

<dialog id="delete-dialog" aria-labelledby="delete-title" aria-describedby="delete-desc">
  <h2 id="delete-title">Delete "Website redesign"?</h2>
  <p id="delete-desc">This removes 42 files for everyone. It cannot be undone.</p>
  <form method="dialog">
    <button value="cancel" autofocus>Cancel</button>
    <button value="confirm">Delete</button>
  </form>
</dialog>

<script>
  const dialog = document.getElementById("delete-dialog");
  dialog.addEventListener("close", () => {
    if (dialog.returnValue === "confirm") deleteProject();
  });
  // Without invoker commands: openButton.onclick = () => dialog.showModal();
</script>
  • showModal() makes the rest of the page inert, puts the dialog in the top layer, moves focus inside and closes on Escape; do not reimplement a focus trap.
  • Put autofocus on the least destructive action. commandfor/command are Baseline newly available since December 2025; keep a showModal() fallback for older browsers.
  • Browsers return focus to the previously focused element on close; verify it in your target browsers when the trigger may be removed from the DOM.
<dialog> + invoker commands documentation (opens in new tab)

TypeScript · React 19 Client

function Disclosure({ title, children }: { title: string; children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  const panelId = useId();
  return (
    <div>
      <h3>
        <button
          type="button"
          aria-expanded={open}
          aria-controls={panelId}
          onClick={() => setOpen((o) => !o)}
        >
          {title}
        </button>
      </h3>
      <div id={panelId} hidden={!open}>
        {children}
      </div>
    </div>
  );
}
// No-JS alternative with the same semantics:
// <details><summary>Shipping</summary>...</details>
  • A real button gives Enter and Space activation and focus for free; aria-expanded on the button announces the state.
  • Use hidden (display: none) so collapsed content leaves the tab order and the accessibility tree.
  • Do not use role="menu" for site navigation dropdowns; it promises arrow-key menu behavior. A disclosure is the right pattern.
React 19 documentation (opens in new tab)

TypeScript · React 19 Client

// Rendered once at app start and kept mounted, so screen readers
// register it before any message is written into it.
function StatusRegion({ message }: { message: string }) {
  return (
    <div role="status" aria-live="polite" aria-atomic="true" className="visually-hidden">
      {message}
    </div>
  );
}

function SearchResults({ query, results, loading }: SearchProps) {
  const message = loading ? "" : `${results.length} results for ${query}`;
  return (
    <>
      <StatusRegion message={message} />
      <ul>{results.map((r) => <li key={r.id}>{r.title}</li>)}</ul>
    </>
  );
}

// Urgent, interrupting messages only:
// <div role="alert">Payment failed. Your card was not charged.</div>
  • A region inserted together with its text is often not announced; keep it mounted and change only its content.
  • The redundant aria-live="polite" on role="status" improves compatibility; avoid pairing role="alert" with aria-live="assertive", which double-speaks in VoiceOver on iOS.
  • Debounce announcements during typing, or users hear every intermediate count.
React 19 documentation (opens in new tab)

TypeScript · React 19 Client

function SignupForm() {
  const [errors, setErrors] = useState<Record<string, string>>({});
  const summaryRef = useRef<HTMLDivElement>(null);
  function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    const found = validate(data); // e.g. { email: "Enter an email like name@example.com" }
    setErrors(found);
    if (Object.keys(found).length === 0) return void signUp(data);
    requestAnimationFrame(() => summaryRef.current?.focus()); // after the summary renders
  }
  const count = Object.keys(errors).length;
  return (
    <form noValidate onSubmit={onSubmit}>
      {count > 0 && <div ref={summaryRef} tabIndex={-1} role="group" aria-labelledby="err-title">
        <h2 id="err-title">Fix {count} problem(s)</h2>
        <ul>{Object.entries(errors).map(([f, m]) => <li key={f}><a href={`#${f}`}>{m}</a></li>)}</ul>
      </div>}
      <label htmlFor="email">Email</label> <input id="email" name="email" type="email" autoComplete="email"
        aria-invalid={!!errors.email} aria-describedby={errors.email ? "email-error" : undefined} />
      {errors.email && <p id="email-error">{errors.email}</p>}
      <button>Create account</button>
    </form>
  );
}
  • Focusing a summary (the pattern GOV.UK uses; W3C's tutorial also suggests focusing the first invalid field) announces it and puts users one Tab away from links to each field; aria-describedby makes each field read its own error.
  • Error text must say how to fix the problem (WCAG 3.3.3), not only that it is invalid, and must not rely on red color alone.
  • autoComplete tokens satisfy Identify Input Purpose and help users who cannot type easily.
React 19 documentation (opens in new tab)

TypeScript · Playwright + axe-core Client

import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("checkout has no automatically detectable WCAG A/AA violations", async ({ page }) => {
  await page.goto("/checkout");
  await page.getByRole("button", { name: "Continue to payment" }).click();
  await page.getByRole("heading", { name: "Payment" }).waitFor(); // let the UI settle

  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"])
    .exclude("#third-party-chat") // excludes the subtree from every rule
    .analyze();

  expect(results.violations).toEqual([]);
});

test("dialog is keyboard operable", async ({ page }) => {
  await page.goto("/projects/1");
  await page.getByRole("button", { name: "Delete project" }).press("Enter");
  await expect(page.getByRole("dialog", { name: /Delete/ })).toBeVisible();
  await page.keyboard.press("Escape");
  await expect(page.getByRole("button", { name: "Delete project" })).toBeFocused();
});
  • axe only sees the current DOM state; scan each meaningful state (menus open, errors shown, dialogs visible).
  • Role-based locators (getByRole with name) double as a check that the accessibility tree is correct.
  • Automated scans catch a fraction of WCAG failures; pair them with keyboard and screen reader passes and testing with disabled users.
Playwright + axe-core documentation (opens in new tab)

WHERE IT BITES

  • Clickable divs and spans: no role, no focus, no Enter or Space, invisible to voice control. Use button or a href.
  • Removing focus outlines (outline: none) without a replacement :focus-visible style, or letting a sticky header cover the focused element.
  • Adding ARIA that lies: role="menu" on navigation, aria-hidden on content that is visible or contains focusable elements, aria-label that differs from the visible label.
  • Single-page apps that change routes without moving focus or updating document.title, so screen reader users get no sign that anything happened.
  • Toasts and inline async errors rendered without a live region, or with a live region mounted at the same moment as its text.
  • Treating a clean axe report or an overlay widget as compliance. Neither covers keyboard flows, reading order, meaningful names or the EAA's documentation duties.

CLOSE THE AI. EXPLAIN THIS.

A custom dropdown built from divs works with a mouse. List everything a screen reader and keyboard user needs from it (role, name, state, keys, focus, announcements), then explain which native element or APG pattern you would replace it with and how you would test the result.

SOURCES

Explainer reviewed