../

Accessibility

HTML-first accessibility: how markup becomes the accessibility tree, when ARIA helps and when it hurts, names, roles, states, focus, live regions and forms, then a WCAG 2.2 AA checklist for HTML authors, a testing workflow and the legal picture as of September 2026. Design-side guidance (contrast, touch targets, states) is in UX & UI; the native widgets that make most ARIA unnecessary are in Interactive elements.

The accessibility tree

Browsers build a second tree from the DOM and hand it to assistive technology (screen readers, voice control, switch access) through the platform's accessibility API. Each node exposes:

PropertyQuestion it answersFrom HTMLFrom ARIA
Rolewhat is it?the element: <button> → button, <nav> → navigationrole="tab"
Namewhat is it called?content, <label>, alt, <caption>, <legend>aria-label, aria-labelledby
Descriptionanything more?title (if not used as the name)aria-describedby
Statewhat condition is it in?disabled, checked, required, openaria-expanded, aria-pressed, aria-invalid
Valuewhat's its value?an input's value, <progress value>aria-valuenow, aria-valuetext
Relationshipswhat does it belong to or control?<label for>, <fieldset>, table headersaria-controls, aria-describedby, aria-owns

A screen reader announces a focused control as roughly name, role, state: "Mute, toggle button, pressed". If any part is missing or wrong, the control is broken for those users even when it looks fine. Inspect it in Chrome DevTools (Elements → Accessibility pane, or the full accessibility tree view), Firefox's Accessibility Inspector, or Safari's Web Inspector (Node → Accessibility).

  • Elements with display: none, visibility: hidden, hidden, inert or aria-hidden="true" are pruned from the tree.
  • opacity: 0, off-screen positioning and clip-path hide visually but stay in the tree: that is how visually hidden text works.
  • CSS can change semantics: display: contents historically dropped the role of buttons and tables in some engines, and list-style: none makes Safari/VoiceOver drop list semantics (add role="list" back if the count matters).

The rules of ARIA use

The W3C note Using ARIA sets out rules for ARIA in HTML. It is now published as a discontinued draft that keeps four rules "for historical purposes and for easier reference", pointing to the APG for further guidance. The wording below is quoted from it.

RuleText (W3C, Using ARIA)In practice
1"If you can use a native HTML element or attribute with the semantics and behavior you require already built in, instead of re-purposing an element and adding an ARIA role, state or property to make it accessible, then do so."<button>, not <div role="button">
2"Do not change native semantics, unless you really have to."not <h2 role="tab">; put the tab inside the heading, or drop the heading
3"All interactive ARIA controls must be usable with the keyboard."a role="slider" needs arrow keys, Home/End
4"Do not use role="presentation" or aria-hidden="true" on a focusable element."a focusable node that says nothing to a screen reader is a "ghost" tab stop
5 (2018 drafts)"All interactive elements must have an accessible name."icon buttons, unlabeled inputs, empty links

Rule 5 appeared in earlier versions (e.g. the 2018 working draft) and is absent from the current discontinued draft; it is still sound advice, and WCAG 4.1.2 requires it anyway.

Why ARIA goes last: it changes only what assistive tech is told. It adds no focus, no keyboard handling and no behavior. role="button" on a div announces "button" to a control that can't be tabbed to or activated with Enter. The WebAIM Million 2026 scan of a million home pages found ARIA on 82.7% of them, and pages with ARIA averaged 59.1 detected errors against 42 for pages without. ARIA correlates with complex widgets, so this isn't causation, but it shows how often ARIA arrives without the behavior it promises.

Landmarks & headings

Screen reader users move around a page by landmarks and headings rather than reading everything. In WebAIM's Screen Reader User Survey #10 (December 2023–January 2024, 1,539 respondents), 71.6% said their first move on a long page is to navigate by headings.

ElementLandmark roleCondition
<header>banneronly when it's not inside article, aside, main, nav or section
<nav>navigationalways; label several: aria-label="Breadcrumb"
<main>mainexactly one visible per page
<aside>complementaryat the top level; label several
<footer>contentinfosame condition as header
<section>regiononly when it has an accessible name (aria-labelledby its heading)
<form>formonly when it has an accessible name
<search>searchBaseline widely available since April 2026
<body>
  <a class="skip-link" href="#main">Skip to content</a>
  <header>…site logo, <nav aria-label="Main">…</nav></header>
  <main id="main" tabindex="-1">
    <h1>Order history</h1>
    <section aria-labelledby="open-h">
      <h2 id="open-h">Open orders</h2>…
    </section>
  </main>
  <aside aria-label="Help">…</aside>
  <footer>…</footer>
</body>
Heading ruleWhy
One h1 naming the page's main contentit is the "you are here"
Don't skip levels going down (h2 → h4)users infer structure from levels
Levels describe structure, not sizestyle with CSS; h3 can look small or big
Every section with a visible title gets a real headinga bold <div> isn't in the headings list
Headings are short and distinctthey're read out of context in a list
Don't put everything in headingsa heading every line is as useless as none

Don't label landmarks with their role ("Navigation navigation"); name what's inside ("Main", "Breadcrumb", "Filters"). The document outline algorithm (sectioning elements resetting heading levels) was never implemented by browsers or screen readers and has been removed from the HTML standard; only h1–h6 levels count. Semantics of each element are in Semantic elements.

Accessible names

The name is computed per the W3C Accessible Name and Description Computation (accname). Simplified, the first rule that yields text wins:

OrderSourceNotes
1hidden nodes skippedunless referenced by aria-labelledby
2aria-labelledbyids of other elements, concatenated in the order listed; can reference hidden text and the element itself
3aria-labela string; invisible to sighted users, may be missed by machine translation
4native label<label>, alt, <caption>, <legend>, <figcaption>, SVG <title>
5contentsonly for roles that allow name from content: buttons, links, headings, cells, tabs, options…
6titlelast resort (the accname "tooltip" step)
(inputs)placeholderHTML-AAM uses it after title for text inputs when nothing else exists; never rely on it
<!-- 2 beats 3 beats 4: name is "Search the docs" -->
<span id="s-l">Search the docs</span>
<input type="search" aria-labelledby="s-l"
  aria-label="Search" id="q">
<label for="q">Query</label>
 
<!-- name from content, including alt of child images -->
<a href="/cart/"><img src="cart.svg" alt="Cart"> (3)</a>
 
<!-- combined names: "Delete Invoice 42" -->
<button id="del-42" aria-labelledby="del-42 inv-42">
  Delete</button>
<span id="inv-42">Invoice 42</span>
RuleDetail
aria-label on non-interactive elementsignored or inconsistent on div, span, p with no role; ARIA prohibits naming generic elements
Label in name (WCAG 2.5.3)the accessible name must contain the visible label, ideally start with it, so voice users can say "click Send"
Don't restate the role"Close", not "Close button"
Names are for identity, descriptions for detailput hints and errors in aria-describedby, not in the name
One source of trutha visible label wired with for/id beats an aria-label copy that drifts

Roles reference

Prefer the native element in the second column. ARIA roles are for patterns HTML lacks.

Widget roles

RolePreferKeyboard you owe with the role
button<button>Enter, Space
link<a href>Enter
checkbox<input type="checkbox">Space; aria-checked
radio / radiogroup<input type="radio"> in a <fieldset>arrows, one tab stop
switchcheckbox (Safari has <input type="checkbox" switch>)Space; aria-checked
textbox<input>, <textarea>editing
searchbox<input type="search">editing
combobox<input list> + <datalist> or <select>arrows, Esc, typeahead; aria-expanded, aria-activedescendant
listbox / option<select> (multiple)arrows, Home/End, typeahead; aria-selected
slider<input type="range">arrows, Page Up/Down, Home/End; aria-valuenow
spinbutton<input type="number">↑/↓
progressbar<progress>none
meter<meter>none
dialog / alertdialog<dialog>Esc, focus containment
tablist / tab / tabpanelnonearrows between tabs, Tab into the panel
menu / menubar / menuitemnone (and usually not what you want)arrows, Esc, typeahead; for app-style command menus only
tree / treeitemnonearrows expand, collapse and move
grid<table> if not interactivearrows cell by cell
tooltippopover="hint" + aria-describedbyEsc

Document structure roles

RolePrefer
heading + aria-level<h1>–<h6>
list / listitem<ul>, <ol>, <li>
table, row, cell, columnheader, rowheader<table>, <tr>, <td>, <th scope>
img<img alt>; role="img" groups an SVG or emoji sequence into one image
figure<figure>
article<article>
group<fieldset>, <details>
separator<hr>
none / presentationremoves the role (e.g. a layout table); children keep theirs
status, alert, log, timerlive regions, below; <output> is a status
generic<div>, <span>

Landmark roles

banner, navigation, main, complementary, contentinfo, region, form, search: all have native elements (table in the previous section). Adding role="main" to <main> is redundant.

States & properties

AttributeValuesUseNative equivalent
aria-expandedtrue, falseon the button that shows/hides something<details>; popovertarget sets it implicitly
aria-controlsid(s)points at the element a control changes; little screen reader support beyond JAWS, harmless to addnone
aria-currentpage, step, location, date, time, truethe current item in a set: nav link to this page, step in a wizardnone; don't use aria-selected for this
aria-pressedtrue, false, mixedtoggle buttons ("Bold", "Mute"); the label stays the same while the state changesnone
aria-selectedtrue, falseselection in tabs, listbox options, grid cells<option selected>
aria-checkedtrue, false, mixedcheckbox, radio, switch, menuitemcheckbox roleschecked, indeterminate
aria-disabledtruedisabled but still focusable and discoverable; you must block activation in JSdisabled: not focusable, not submitted, no events
aria-hiddentrueremoves decorative or duplicate content from the tree; never on focusable elements or their ancestorshidden, inert also hide from everyone
aria-liveoff, polite, assertiveannounce changes inside the region<output> (implicit status)
aria-atomictrue, falseread the whole region, not just the changed node
aria-relevantadditions, removals, text, allwhich changes to announce; default additions text; rarely needed
aria-busytrue, falseregion is updating; AT may hold announcements until false (support varies)
aria-describedbyid(s)hints, format rules, error text: read after name and rolenone
aria-invalidtrue, false, grammar, spellingthe value failed validation (set it after the user submits or leaves the field):user-invalid is visual only
aria-errormessageidthe error text for an aria-invalid="true" field; support has lagged, so also reference the error from aria-describedbynone
aria-requiredtruerequired custom controlsrequired on inputs
aria-haspopuptrue/menu, listbox, tree, grid, dialogannounces that activation opens that kind of popup; true means menu, so don't add it to plain disclosure buttons<select>, <input list>
aria-modaltrueon a custom role="dialog": tells AT to ignore the page behindshowModal() sets it
aria-label, aria-labelledbystring, id(s)name (see above)<label>, alt, content

disabled versus aria-disabled, side by side (Tab through them):

disabled.html
<style>
  button { font: inherit; padding: 6px 12px; margin: 4px; }
  [aria-disabled="true"] { opacity: 0.55;
    cursor: not-allowed; }
  :focus-visible { outline: 2px solid var(--graph-0);
    outline-offset: 2px; }
</style>
<button>Enabled</button>
<button disabled>disabled (skipped by Tab)</button>
<button aria-disabled="true"
  aria-describedby="why">aria-disabled (focusable)</button>
<p id="why" style="color:var(--muted)">Complete the form to
  enable saving.</p>
Result

Use aria-disabled when users need to find the control and learn why it is unavailable (a submit button explained by a hint); use disabled when it is simply irrelevant.

Keyboard & focus

tabindex

ValueEffectUse
(none) on native controlsfocusable in DOM orderthe default; nearly always right
0adds a non-interactive element to the tab order in DOM ordercustom widgets with a role and keyboard handling; scrollable regions
-1focusable by script and by click, not by Tabfocus targets: headings after route changes, <main> for a skip link, inactive items in a roving group
positive (1+)jumps ahead of everything else, in number ordernever: it breaks visual order (WCAG 2.4.3) and every later edit
  • Tab order is DOM order. If CSS (order, grid-area, flex-direction: row-reverse, absolute positioning) puts things elsewhere visually, fix the DOM, not the tabindex.
  • Keep focus visible (WCAG 2.4.7): never outline: none without a replacement. Style :focus-visible, which shows for keyboard focus and not mouse clicks on buttons. A ring recipe is in UX & UI.
  • Don't let sticky headers or cookie banners cover the focused element (2.4.11): html { scroll-padding-block-start: 5rem; } sized to the sticky header.
  • Scrollable regions need to be keyboard-scrollable: recent Chromium and Firefox make them focusable automatically; add tabindex="0", a role and a name to be safe.

Roving tabindex

A composite widget (toolbar, tab list, radio-like group, grid) is one tab stop; arrow keys move within it. Exactly one item has tabindex="0", the rest -1, and arrow key handling moves the 0 and calls focus(). Toggle buttons in a toolbar add aria-pressed.

<div role="toolbar" aria-label="Text formatting">
  <button tabindex="0">Bold</button>
  <button tabindex="-1">Italic</button>
  <button tabindex="-1">Underline</button>
</div>
function onKey(e: KeyboardEvent): void {
  const items = [...toolbar.querySelectorAll("button")];
  const i = items.indexOf(e.target as HTMLButtonElement);
  const next =
    e.key === "ArrowRight" ? (i + 1) % items.length
    : e.key === "ArrowLeft"
      ? (i - 1 + items.length) % items.length
    : e.key === "Home" ? 0
    : e.key === "End" ? items.length - 1
    : -1;
  if (next < 0) return;
  e.preventDefault();
  items[i]!.tabIndex = -1;
  items[next]!.tabIndex = 0;
  items[next]!.focus();
}

The alternative, aria-activedescendant, keeps DOM focus on the container (or a combobox input) and points at the active option by id: use it when focus must stay in a text input. The focusgroup attribute, which would do this natively, is limited availability: not shipped across engines yet.

Managing focus

EventMove focus toWhy
Client-side route changethe new page's h1 (with tabindex="-1") or <main>; also update document.titleotherwise focus stays on the clicked link, now gone or meaningless, and nothing is announced
Modal opensinside the dialog (showModal() does it; use autofocus to choose)2.4.3
Modal closesback to the opener (native dialogs and popovers do it)users continue where they were
Item deletedthe next item, or the list heading if emptyfocus on a removed node falls back to <body>
Form submitted with errorsthe error summary, or the first invalid fielderrors are found immediately
Content loaded ("Load more")the first new itemdon't make users hunt
Toast or statusdon't move focus; use a live region4.1.3

Skip links (2.4.1) are in Recipes below.

Live regions & status messages

A live region is an element whose later changes are announced without moving focus. WCAG 4.1.3 requires status messages ("3 results", "Saved", "Item added to cart") to be announced this way.

MarkupPolitenessAtomicUse
role="status" / <output>polite: waits for the user to finishtrueresults counts, "Saved", progress milestones
role="alert"assertive: interruptstrueerrors and time-critical warnings only
role="log"politefalsechat, activity feeds (new lines only)
aria-live="polite"politeset aria-atomic as neededcustom regions
aria-live="assertive"assertiverarely; prefer role="alert"
<!-- in the initial HTML, empty -->
<div id="cart-status" role="status"></div>
const status = document.querySelector("#cart-status")!;
 
export function announce(message: string): void {
  status.textContent = "";          // allow repeats
  requestAnimationFrame(() => {
    status.textContent = message;   // this change is read
  });
}
announce("Added to cart. 3 items.");
RuleWhy
The region must exist, and be rendered, before the content changesscreen readers watch regions they already know about; inserting <div role="alert">Error</div> in one go is often not announced (alerts are the most forgiving)
Don't hide the region with display: noneit leaves the tree; use a visually hidden class if it shouldn't be seen
Keep messages short and complete"Saved", not "The operation has completed successfully"
Use polite by defaultassertive messages cut off whatever the user was hearing
No interactive content insideit's read as text; links in a toast are unreachable if it disappears
Toasts that vanishgive them at least several seconds, pause on hover and focus, and put anything actionable somewhere persistent
Don't announce everythingtyping feedback on every keystroke is noise; debounce

ariaNotify() (announce a string without a live region) reached Baseline in September 2026 (Chrome 141, Firefox 150, Safari 27), so it is newly available: use it with a live-region fallback for now.

Forms

The full form sheet is Forms & inputs; the accessibility core:

RequirementMarkupWCAG
Every control has a visible label<label for="email">Email</label><input id="email"> or wrap the input in the label1.3.1, 3.3.2, 4.1.2
Related controls are grouped<fieldset><legend>Delivery</legend>…</fieldset>, always for radio groups1.3.1
Hints are attachedaria-describedby="pw-hint"1.3.1
Required is marked in text and in coderequired plus a visible "(required)" or asterisk explained once3.3.2
Errors are identified in textmessage next to the field, aria-invalid="true", linked via aria-describedby3.3.1
Errors say how to fix"Enter a date like 21/03/2026", not "Invalid"3.3.3
Personal-data fields declare their purposeautocomplete="email", "given-name", "street-address", "postal-code", "tel", "cc-number", "bday"1.3.5
Logins work with password managers and pasteautocomplete="username", "current-password", "new-password", "one-time-code"; never block paste3.3.8
Don't ask twiceprefill or offer "same as billing"3.3.7
Legal, financial, data-deleting submissions can be reviewed, corrected or reverseda confirm step or undo3.3.4
No change of context on inputa <select> must not navigate on change; use a submit button3.2.2
  • A placeholder is not a label: it disappears on typing, often fails contrast, and support as a name is a last-resort fallback.
  • Native validation bubbles aren't fully accessible and can't be styled. A common pattern: novalidate on the form, validate on submit, then show inline messages and an error summary (Recipes).
  • Mark the fewer case: if most fields are required, mark the optional ones instead, and say so at the top.

Images, media & color

TopicRuleDetails
Imagesalt by purpose: informative, decorative (alt=""), functional, complexMedia & embeds
SVGrole="img" + name when meaningful; aria-hidden="true" when decorativeMedia & embeds
Videocaptions (1.2.2, 1.2.4), audio description (1.2.5), no unmuted autoplay, pause for motion (2.2.2)Media & embeds
Audiotranscript (1.2.1); auto-playing audio over 3 s needs a control (1.4.2)
Iframesa title naming the content
Contrasttext 4.5:1, large text 3:1, UI and graphics 3:1 (1.4.3, 1.4.11)Color theory
Color alonenever the only signal: add text, icons or patterns (1.4.1)
Motionhonor prefers-reduced-motion; nothing flashes more than 3 times a second (2.3.1)Animation
Forced colorstest Windows High Contrast (forced-colors: active); use currentColor and real bordersCSS

WCAG 2.2 A & AA checklist for HTML authors

WCAG 2.2 became a W3C Recommendation on 5 October 2023. It has 86 success criteria, 55 of them at levels A and AA, which is what laws and contracts usually require. It added nine criteria (six at A/AA, marked new) and removed 4.1.1 Parsing as obsolete: browsers and assistive tech no longer depend on strictly valid markup, and the problems it covered (duplicate ids, bad nesting) are caught by 1.3.1 and 4.1.2 where they matter.

SCLevelCheck in the markup
1.1.1 Non-text contentAalt on every img, names on icon buttons, aria-hidden on decorative SVG
1.2.1 Audio-only and video-only (prerecorded)Atranscript, or a text/audio description for silent video
1.2.2 Captions (prerecorded)A<track kind="captions">, corrected
1.2.3 Audio description or media alternativeAdescription track or full text alternative
1.2.4 Captions (live)AAlive captioning for streams
1.2.5 Audio description (prerecorded)AAnarrated description of visual-only information
1.3.1 Info and relationshipsAheadings, lists, tables with th, labels, fieldsets, landmarks in markup
1.3.2 Meaningful sequenceADOM order matches reading order
1.3.3 Sensory characteristicsAnot "click the round green button" alone
1.3.4 OrientationAAno forced portrait or landscape
1.3.5 Identify input purposeAAautocomplete tokens on personal-data fields
1.4.1 Use of colorAerrors, links, chart series not by color alone
1.4.2 Audio controlAno auto-playing audio over 3 s without a control
1.4.3 Contrast (minimum)AA4.5:1 text, 3:1 large text
1.4.4 Resize textAAusable at 200% zoom; rem/em, no fixed-height text boxes
1.4.5 Images of textAAreal text, not pictures of it
1.4.10 ReflowAAno horizontal scroll at 320 CSS px wide (400% zoom)
1.4.11 Non-text contrastAA3:1 for borders of inputs, icons, focus rings
1.4.12 Text spacingAAno clipping when line height is 1.5 and spacing grows
1.4.13 Content on hover or focusAAtooltips dismissible (Esc), hoverable, persistent
2.1.1 KeyboardAevery action works with the keyboard alone
2.1.2 No keyboard trapAfocus can always leave (modals via Esc or a button)
2.1.4 Character key shortcutsAsingle-key shortcuts can be turned off or remapped
2.2.1 Timing adjustableAsession timeouts warn and can be extended
2.2.2 Pause, stop, hideAcarousels, auto-playing video and tickers can be paused
2.3.1 Three flashes or below thresholdAnothing flashes more than 3 times per second
2.4.1 Bypass blocksAskip link and/or landmarks
2.4.2 Page titledAa unique, descriptive <title>, updated on route change
2.4.3 Focus orderAno positive tabindex; logical DOM order; focus managed on dialogs
2.4.4 Link purpose (in context)Ano bare "click here"; context or aria-describedby
2.4.5 Multiple waysAAsearch, sitemap or nav, not a single route to each page
2.4.6 Headings and labelsAAheadings and labels describe their content
2.4.7 Focus visibleAAa visible :focus-visible style everywhere
2.4.11 Focus not obscured (minimum) newAAsticky headers and banners don't fully cover the focused element
2.5.1 Pointer gesturesApinch and multi-finger gestures have single-pointer alternatives
2.5.2 Pointer cancellationAactions fire on up-event (click), not pointerdown
2.5.3 Label in nameAthe accessible name contains the visible label
2.5.4 Motion actuationAshake or tilt features have a button alternative
2.5.7 Dragging movements newAAdrag-to-reorder, sliders, maps have click or keyboard alternatives
2.5.8 Target size (minimum) newAAtargets ≥ 24 × 24 CSS px or spaced so a 24 px circle doesn't overlap another target; inline links exempt
3.1.1 Language of pageA<html lang="en">
3.1.2 Language of partsAAlang on passages in another language
3.2.1 On focusAfocusing something doesn't submit, navigate or open windows
3.2.2 On inputAchanging a value doesn't change context without warning
3.2.3 Consistent navigationAAnav in the same order across pages
3.2.4 Consistent identificationAAthe same function has the same name everywhere
3.2.6 Consistent help newAhelp links or contact details in the same relative place on each page
3.3.1 Error identificationAerrors described in text
3.3.2 Labels or instructionsAvisible labels and format hints
3.3.3 Error suggestionAAsay how to fix it
3.3.4 Error prevention (legal, financial, data)AAreview, confirm or undo
3.3.7 Redundant entry newAdon't make users re-enter information they already gave in the same process
3.3.8 Accessible authentication (minimum) newAAno cognitive test to log in unless there's an alternative; allow paste and password managers; object-recognition CAPTCHAs are an allowed exception
4.1.2 Name, role, valueAnative elements, or complete ARIA for custom widgets
4.1.3 Status messagesAArole="status" / role="alert" for messages that don't take focus

Also in 2.2 but AAA: 2.4.12 Focus not obscured (enhanced), 2.4.13 Focus appearance and 3.3.9 Accessible authentication (enhanced). The design-side subset with quick checks is in UX & UI.

Testing workflow

  1. Automated scan

    Run axe DevTools or Lighthouse on each template, and axe-core in CI (e.g. with Playwright, see Testing). Fix everything flagged: these are the cheap wins.

  2. Keyboard-only pass

    Put the mouse away. Tab and Shift+Tab through the page and check:

    • every control is reachable, in a sensible order, with a visible focus ring
    • Enter and Space work on buttons; arrows inside composite widgets
    • menus and dialogs close with Esc and return focus
    • nothing traps focus; nothing focused is hidden under sticky UI
  3. Screen reader pass

    Use one desktop and one mobile screen reader. Navigate by headings, landmarks and form fields, then complete the page's main task. Listen for missing names, wrong roles and unannounced changes.

  4. Zoom and reflow

    Browser zoom to 200% and 400% (or a 320 px wide window). Nothing is cut off, overlapping or scrolling sideways. Apply a text-spacing bookmarklet.

  5. Visual settings

    Check contrast, prefers-reduced-motion, Windows forced colors and dark mode.

  6. Users

    Test with disabled people for anything important. Checklists find defects; people find whether the product is usable.

Screen readers

Screen readerPlatformBrowser to pairStartEssential keys
VoiceOvermacOS (built in)Safari⌘ F5VO = Ctrl+Option; VO+→ / ← next/previous; VO+U rotor (headings, landmarks, links, form controls); VO+⌘+H next heading; VO+Space activate; Ctrl stops speech
NVDAWindows (free)Firefox or ChromeCtrl+Alt+NNVDA key = Insert; H / Shift+H headings, 1–6 by level, D landmarks, K links, F form fields, B buttons, T tables; NVDA+F7 elements list; NVDA+Space browse/focus mode; Ctrl stops speech
JAWSWindows (paid; 40-minute demo mode)Chrome or Edgedesktop shortcutH headings, R regions (landmarks), F form fields, T tables; Insert+F6 headings list, Insert+F7 links list, Insert+F5 form fields list; Insert+Z toggles the virtual cursor
VoiceOveriOS, iPadOSSafariSettings → Accessibility, or the Accessibility Shortcut (triple-click side button)swipe right/left next/previous; double-tap activate; rotate two fingers for the rotor, then swipe up/down to jump by the chosen unit
TalkBackAndroidChromeSettings → Accessibility, or hold both volume keys if the shortcut is onswipe right/left next/previous; double-tap activate; reading controls to jump by headings, links, controls

In WebAIM's Survey #10 the primary desktop screen readers were JAWS (40.5%), NVDA (37.7%) and VoiceOver (9.7%); on mobile, 70.6% used VoiceOver and 34.7% TalkBack. The commonest pairings were JAWS with Chrome and NVDA with Chrome. Test with NVDA + Chrome or Firefox and VoiceOver + Safari at minimum.

What automated tools miss

Automated checkers only test what can be decided from code: missing alt, missing labels, contrast of plain text, invalid ARIA, duplicate ids. They can't judge whether alt text is accurate, whether focus order makes sense, whether a custom widget is operable, or whether an announcement ever happens.

StudyFinding
GOV.UK (Government Digital Service), 2017on a test page with 143 deliberate failures, the best single tool found 37–41% depending on how warnings were counted; all tools together found 71%
Deque, Automated accessibility testing coverageacross 2,000+ audits and nearly 300,000 issues, Deque's automated (axe) tests found 57.38% of issues by volume; the far lower figures often quoted count WCAG criteria covered instead

The same WebAIM Million 2026 run found detectable WCAG failures on 95.9% of a million home pages, averaging 56.1 per page. The six most common: low-contrast text (83.9% of pages), missing alt text (53.1%), missing form labels (51%), empty links (46.3%), empty buttons (30.6%) and missing lang (13.5%). All six are one-line HTML or CSS fixes.

Laws usually reference WCAG rather than restating it. This is orientation, not legal advice.

JurisdictionInstrumentStandardStatus (September 2026)
US state and local governmentADA Title II final rule, published 24 April 2024WCAG 2.1 AA for web content and mobile appsan Interim Final Rule (20 April 2026) extended compliance to 26 April 2027 for entities serving 50,000+ people and 26 April 2028 for smaller ones and special districts
US federal agenciesSection 508 (2017 refresh)WCAG 2.0 AAin force
US private businessesADA Title IIIno technical regulation; lawsuits and settlements commonly cite WCAG 2.x AAlitigation-driven
EU private sectorEuropean Accessibility Act, Directive (EU) 2019/882harmonized standard EN 301 549, which builds its web requirements on WCAG AAapplies to products placed on the market and services provided to consumers after 28 June 2025; e-commerce, banking, e-books, transport ticketing, communications; microenterprises providing services are exempt
EU public sectorWeb Accessibility Directive (EU) 2016/2102EN 301 549in force

Target WCAG 2.2 AA: it is a superset of 2.1 AA apart from the removed 4.1.1, so it satisfies rules written against 2.1.

Common mistakes

MistakeFix
<div>/<span> click handlers<button> or <a href>
Icon buttons with no namearia-label or visually hidden text
aria-label on a div with no rolename something interactive or a landmark; otherwise use visible text
aria-hidden="true" on a container with focusable childreninert, or remove them from the tab order
role="button" without tabindex="0" and key handlersuse <button>
Redundant roles (<nav role="navigation">, <button role="button">)delete them
role="menu" for site navigationa list of links; menus are for application commands
outline: none with no replacementa :focus-visible ring
Positive tabindexDOM order
Live region added at the same moment as its textrender it empty at load, then fill it
Heading levels chosen for sizepick by structure, style with CSS
Placeholder as the only labela real <label>
Errors shown only in redtext plus an icon; aria-invalid and aria-describedby
"Click here", "Read more" ×10descriptive link text, or aria-describedby to the item heading
Missing lang<html lang="…">
Overlays and widgets sold as automatic compliancethey don't fix the underlying markup; fix the HTML
Testing only with axeadd keyboard and screen reader passes

Recipes

Hidden until focused; the first Tab on the page reveals it. Tab into the frame to see it:

skip-link.html
<style>
  .skip-link { position: absolute; left: 8px; top: -40px;
    padding: 8px 12px; border-radius: 6px; z-index: 10;
    background: var(--fg); color: var(--bg); }
  .skip-link:focus { top: 8px; }
  nav a { margin-inline-end: 12px; color: inherit; }
</style>
<a class="skip-link" href="#main">Skip to main content</a>
<nav aria-label="Main" style="margin-top:44px">
  <a href="#a">Products</a><a href="#b">Pricing</a>
  <a href="#c">Docs</a>
</nav>
<main id="main" tabindex="-1">
  <h3>Main content</h3>
</main>
Result
  • The target needs tabindex="-1" (or be focusable) so focus, not just the scroll position, moves there in every browser.
  • Make it the first focusable element in the <body>.
  • Several skip links ("Skip to search", "Skip to results") suit long, complex pages.

Visually hidden utility

Hidden on screen, read by screen readers; becomes visible when it or a descendant receives focus, so it doubles for skip links:

.visually-hidden:not(:focus-within, :active) {
  position: absolute !important;
  inline-size: 1px;
  block-size: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;  /* no word-by-word reading */
  border: 0;
}

Never use display: none or visibility: hidden for this: both remove the text from the accessibility tree. Tailwind's sr-only is the same idea.

Accessible icon button

<!-- visually hidden text: survives machine translation -->
<button type="button" class="icon-btn">
  <svg aria-hidden="true" width="20" height="20">
    <use href="/icons.svg#bell" />
  </svg>
  <span class="visually-hidden">Notifications</span>
</button>
 
<!-- or aria-label on the button -->
<button type="button" class="icon-btn"
  aria-label="Notifications">
  <svg aria-hidden="true" width="20" height="20">
    <use href="/icons.svg#bell" />
  </svg>
</button>

Make the target at least 24 × 24 CSS px (2.5.8; 44 px is better on touch), give it a visible focus ring, and if it's a toggle, add aria-pressed rather than changing the name.

Disclosure button with aria-expanded

When <details> doesn't fit (the trigger sits apart from the panel, or needs custom markup):

<button type="button" class="disclosure"
  aria-expanded="false" aria-controls="filters">
  Filters
</button>
<div id="filters" hidden>…</div>
for (const btn of document.querySelectorAll<HTMLElement>(
  "button[aria-expanded][aria-controls]",
)) {
  const panel = document.getElementById(
    btn.getAttribute("aria-controls")!,
  )!;
  btn.addEventListener("click", () => {
    const open =
      btn.getAttribute("aria-expanded") === "true";
    btn.setAttribute("aria-expanded", String(!open));
    panel.hidden = open;
  });
}
.disclosure::after {
  content: "▸";
  margin-inline-start: 0.5ch;
}
.disclosure[aria-expanded="true"]::after { content: "▾"; }

Keep the name constant ("Filters"); the state carries open or closed. Put the panel right after the button in the DOM so the reading and tab order follow.

Form error pattern

Inline message referenced by the field, aria-invalid on the field, and an error summary focused on submit:

form-errors.html
<style>
  .summary { border: 2px solid var(--graph-1); padding: 8px;
    border-radius: 6px; margin-block-end: 12px; }
  .summary h3 { margin: 0 0 4px; font-size: 15px; }
  .summary a { color: var(--graph-1); }
  label { display: block; font-weight: 600; }
  input { font: inherit; padding: 6px; margin-block: 4px;
    border: 1px solid var(--muted); border-radius: 4px; }
  input[aria-invalid="true"] {
    border: 2px solid var(--graph-1); }
  .error { color: var(--graph-1); margin: 0; }
  .hint { color: var(--muted); margin: 0; }
</style>
<div class="summary" role="alert" tabindex="-1">
  <h3>There is 1 problem</h3>
  <a href="#email">Enter an email address like
    name@example.com</a>
</div>
<label for="email">Email</label>
<p class="hint" id="email-hint">We'll send the receipt
  here.</p>
<input id="email" type="email" autocomplete="email"
  aria-invalid="true"
  aria-describedby="email-hint email-err"
  value="ana@">
<p class="error" id="email-err">Error: enter an email
  address like name@example.com</p>
Result
form.addEventListener("submit", (e) => {
  const invalid = [...form.elements].filter(
    (el): el is HTMLInputElement =>
      el instanceof HTMLInputElement && !el.checkValidity(),
  );
  if (invalid.length === 0) return;
  e.preventDefault();
  for (const el of invalid) {
    el.setAttribute("aria-invalid", "true");
    showMessage(el);     // fills the linked .error element
  }
  renderSummary(invalid); // links to each field's id
  summary.focus();
});
  • Add novalidate to the <form> so browser bubbles don't compete with your messages.
  • The word "Error:" (and optionally an icon) means the message doesn't rely on color (1.4.1).
  • Remove aria-invalid and the message when the user fixes the field, not on every keystroke before they finish.

Live status message

<button type="button" id="save">Save draft</button>
<p id="save-status" role="status"
  class="visually-hidden"></p>
const status = document.querySelector("#save-status")!;
 
saveButton.addEventListener("click", async () => {
  status.textContent = "Saving…";
  await saveDraft();
  const time = new Date().toLocaleTimeString([], {
    timeStyle: "short",
  });
  status.textContent = `Draft saved at ${time}`;
});

The <p role="status"> is in the initial HTML. Drop visually-hidden if sighted users should see the message too, which is usually better.

References