HTML & CSS NotesRohit’s interview study guide
Chapter 12

Accessibility

Building pages everyone can use: WCAG 2.2 and the law, native HTML before ARIA, roles and accessible names, keyboard focus, contrast, hiding content correctly, accessible forms, live regions and how to test, each checked against Chrome's own accessibility tree.

11 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#What accessibility is and who it's for

Added

Accessibility (a11y) means people with disabilities can perceive, understand, navigate and operate what you build. That covers people who are blind and use a screen reader (VoiceOver, NVDA, JAWS, TalkBack), people with low vision who zoom to 400% or use high-contrast modes, colour-blind users, deaf and hard-of-hearing users who need captions, people with motor impairments who use only a keyboard, a switch or voice control, and people with cognitive disabilities who need clear language and predictable interfaces. The WHO estimates about 1.3 billion people, 16% of the world's population, have a significant disability. Add temporary and situational cases (a broken wrist, bright sunlight on a phone, a noisy train) and accessible design helps almost everyone.

The mental model for developers: the browser builds a second tree from your DOM, the accessibility tree, and assistive technology reads that tree, not your pixels. Every node in it has a role (button, link, heading), a name ("Save"), states (expanded, checked, disabled) and a value. Most accessibility work is making sure that tree tells the truth, and that everything a mouse user can do, a keyboard user can do too. This chapter checks the tree with Chrome's own computation (el.computedRole, el.computedName) in every demo.

The standard is WCAG 2.2 (Web Content Accessibility Guidelines), a W3C Recommendation since 5 October 2023 (updated 12 December 2024) and, since October 2025, also the international standard ISO/IEC 40500:2025. Its success criteria are grouped under four principles, POUR:

PrincipleMeansExamples of success criteria
PerceivableInformation can be seen, heard or read by assistive tech1.1.1 Non-text Content (alt text), 1.4.3 Contrast (Minimum), 1.2.2 Captions
OperableEverything works with keyboard and other inputs, in reasonable time2.1.1 Keyboard, 2.4.7 Focus Visible, 2.5.8 Target Size (Minimum)
UnderstandableContent and behaviour are predictable and clear3.3.1 Error Identification, 3.3.2 Labels or Instructions, 3.2.6 Consistent Help
RobustWorks with current and future assistive tech4.1.2 Name, Role, Value, 4.1.3 Status Messages

Each criterion has a level. A is the minimum, AA is what laws and contracts almost always require, AAA is enhanced and not expected for whole sites. Counting the W3C document itself: WCAG 2.2 has 86 success criteria, 31 at level A, 24 at AA and 31 at AAA, so AA conformance means meeting 55 of them. 4.1.1 Parsing is marked obsolete and removed. WCAG 2.2 added nine criteria: 2.4.11 Focus Not Obscured (Minimum) (AA), 2.4.12 Focus Not Obscured (Enhanced) (AAA), 2.4.13 Focus Appearance (AAA), 2.5.7 Dragging Movements (AA), 2.5.8 Target Size (Minimum) (AA), 3.2.6 Consistent Help (A), 3.3.7 Redundant Entry (A), 3.3.8 Accessible Authentication (Minimum) (AA) and 3.3.9 Accessible Authentication (Enhanced) (AAA). The demo measures one of the new ones.

2.5.8 Target Size (Minimum): 24 by 24 CSS pixels, or enough space
CSS
.row { display: flex; align-items: center; margin-bottom: 10px; }
.row button { padding: 0; border: 0; border-radius: 4px; background: rgb(219, 234, 254); color: rgb(30, 64, 175); font: 600 12px/1 sans-serif; }
.s16 button { width: 16px; height: 16px; }
.s24 button { width: 24px; height: 24px; }
.s44 button { width: 44px; height: 44px; }
.tight { gap: 2px; }
.spaced { gap: 10px; }
HTML
<div class="row s16 tight"><button aria-label="Bold">B</button><button aria-label="Italic">I</button><button aria-label="Underline">U</button></div>
<div class="row s16 spaced"><button aria-label="Bold">B</button><button aria-label="Italic">I</button><button aria-label="Underline">U</button></div>
<div class="row s24 tight"><button aria-label="Bold">B</button><button aria-label="Italic">I</button><button aria-label="Underline">U</button></div>
<div class="row s44 tight"><button aria-label="Bold">B</button><button aria-label="Italic">I</button><button aria-label="Underline">U</button></div>
Resultlive, in your browser
What’s happening
  1. The criterion: a pointer target must be at least 24 × 24 CSS pixels, unless one of five exceptions applies. The most useful is Spacing: draw a 24px-diameter circle centred on each undersized target; if no circle touches another target or another undersized target's circle, it passes. The test implements exactly that rule.
  2. Row 1: 16px buttons with a 2px gap. Their centres are 18px apart, so the 24px circles overlap: fails. These are the cramped toolbar icons that users with tremors (or anyone on a phone) mis-tap.
  3. Row 2: the same 16px buttons with a 10px gap. Centres are now 26 px apart, more than one circle's diameter, so the spacing exception applies: passes, without making the icons bigger.
  4. Row 3: 24px buttons pass on size alone, even with a 2px gap. Row 4 is 44px, the AAA level (2.5.5 Target Size (Enhanced)) and close to Apple's and Google's touch-target guidance.
  5. Each button is a single letter, so it gets its accessible name from aria-label: Chrome computes "Bold", "Italic", "Underline". A screen reader says "Bold, button", not "B, button".
  6. Automated tools can check this criterion (axe-core has a target-size rule), but most of WCAG can't be measured like this; see Testing accessibility.
AdvancedEAA, ADA and the state of the web
  • European Union: the European Accessibility Act (Directive (EU) 2019/882) has applied since 28 June 2025. It covers products and services such as e-commerce, banking, e-books, passenger transport services, telephony, computers, smartphones, ATMs and ticketing kiosks. Microenterprises providing services (fewer than 10 staff and no more than €2 million turnover or balance sheet) are exempt from the service requirements. The technical yardstick is the harmonised standard EN 301 549; version 3.2.1 maps web content to WCAG 2.1 AA. Version 4.1.1, published in September 2026, moves to WCAG 2.2, but as of October 2026 it is not yet cited in the EU's Official Journal (expected around the end of 2026), so 3.2.1 remains the reference.
  • United States: ADA Title II (state and local governments). The Department of Justice's 2024 rule requires WCAG 2.1 AA for websites and apps. An interim final rule in April 2026 moved the compliance dates by a year, to 26 April 2027 for entities serving 50,000 people or more and 26 April 2028 for smaller ones and special districts; the standard didn't change.
  • United States: ADA Title III (businesses open to the public) has no web regulation, but many lawsuits allege inaccessible sites, and settlements and courts usually point to WCAG 2.x AA. Section 508 requires federal agencies' ICT to meet WCAG 2.0 AA.
  • Practically: target WCAG 2.2 AA. It's a superset of 2.1 AA (apart from the removed 4.1.1), so it satisfies the 2.1-based laws and is where the EU standard is heading.
Which level?

A client says "we need to be WCAG compliant". Which level do you aim for, and which version?

Show answer

WCAG 2.2 Level AA. Level AA is what laws and procurement rules reference; AAA is not expected for whole sites.

What’s happening
  1. Conformance levels are cumulative: AA means every A and every AA criterion (55 in WCAG 2.2) is met. There is no "AA except for the hard ones".
  2. The EU (via EN 301 549 v3.2.1) and the US ADA Title II rule cite WCAG 2.1 AA; WCAG 2.2 AA includes all of 2.1 AA except the removed 4.1.1 Parsing, so meeting 2.2 AA satisfies both and anticipates EN 301 549 v4.1.1.
  3. Conformance is per page (and per complete process, like a checkout), so one inaccessible step breaks the whole flow.
  4. AAA criteria are still worth adopting where they're cheap, such as 2.5.5's 44px targets or 1.4.6's 7:1 contrast for body text.

#The first rule of ARIA: use native HTML

Added

ARIA (WAI-ARIA, Accessible Rich Internet Applications) is a set of attributes (role, aria-*) that change what an element reports in the accessibility tree. That's all it does: ARIA changes semantics, never behaviour. role="button" makes a <div> announce itself as a button, but it doesn't make it focusable, doesn't make Enter or Space activate it, and doesn't make it submit a form or disable itself.

That's why the W3C's "Using ARIA" note opens with its first rule: if a native HTML element or attribute has the semantics and behaviour you need, use it instead of re-purposing an element and adding ARIA. A <button> gives you the role, the name from its content, focusability, keyboard activation, disabled, form submission and high-contrast-mode styling for free. The mental model: ARIA is a label you stick on a box. It changes what the box says it is, but if the box was empty, it's still empty.

Three "buttons", three different trees
CSS
.btn { display: inline-block; margin: 0 8px 8px 0; padding: 8px 16px; border: 0; border-radius: 6px; background: rgb(37, 99, 235); color: #fff; font: inherit; cursor: pointer; }
HTML
<button type="button" class="btn" id="native">Save</button>
<div class="btn" id="aria" role="button" tabindex="0">Save</div>
<div class="btn" id="plain">Save</div>
fake-button.js
// What the role="button" div still needs, in JavaScript:
function makeButton(el, onActivate) {
  el.addEventListener("click", onActivate);
  el.addEventListener("keydown", (e) => {
    if (e.key === "Enter") onActivate(e);
    if (e.key === " ") e.preventDefault(); // stop Space from scrolling the page
  });
  el.addEventListener("keyup", (e) => {
    if (e.key === " ") onActivate(e); // native buttons activate on Space release
  });
}
Resultlive, in your browser
What’s happening
  1. All three look identical. In the accessibility tree they're not: the <button> and the role="button" div both compute to role button with name "Save"; the plain div is generic with no name, so a screen reader user hears the text "Save" as plain text, if they find it at all.
  2. Focus: the native button is focusable by default. The ARIA div is focusable only because of tabindex="0" (remove it and keyboard users can't reach it). The plain div can't receive focus at all: focus() does nothing.
  3. Activation: a native <button> is activated by Enter (on key down) and Space (on key up) by the browser itself. That can't be demonstrated from a test, because events dispatched by script are untrusted and trigger no default actions; the test shows that a script-dispatched Enter produced 0 clicks. The behaviour is defined by the HTML spec and browsers, not by your code.
  4. The ARIA div gets none of that. makeButton is the code it needs: click, Enter on keydown, Space on keyup, and preventDefault() on Space's keydown so the page doesn't scroll. The test runs this exact function: Enter, Space and a click each called the handler once (3 total) and Space's default was prevented. And it still lacks disabled, form submission and Windows high-contrast styling.
  5. This is the first rule of ARIA in practice: the native element is less code, fewer bugs and better support. Use role="button" only when you truly can't change the element (third-party markup), and then implement everything above.
AdvancedFive rules of ARIA and native widgets

The five rules of ARIA use

The W3C note "Using ARIA" lists five rules. Interviewers like the first and the fourth:

  1. Use native HTML elements and attributes instead of ARIA whenever one exists.
  2. Don't change native semantics unless you really have to: not <h2 role="tab">, but <div role="tab"><h2>…</h2></div>.
  3. All interactive ARIA controls must be usable with the keyboard.
  4. Don't use role="presentation" or aria-hidden="true" on a focusable element: keyboard users would land on something that, to a screen reader, doesn't exist.
  5. All interactive elements must have an accessible name.

#ARIA roles, states and properties

Added

ARIA has three kinds of attribute. A role says what something is: role="tab", role="dialog", role="switch". Properties describe relationships and characteristics that rarely change: aria-label, aria-labelledby, aria-describedby, aria-controls, aria-haspopup, aria-required. States describe the current condition and change with interaction: aria-expanded, aria-pressed, aria-checked, aria-selected, aria-current, aria-invalid, aria-disabled. (The spec's line between "states" and "properties" is fuzzy; what matters is that states must be kept in sync by your script.)

Roles come in categories: landmark roles (banner, navigation, main, complementary, contentinfo, region, search, form), document structure (heading, list, article, img, table), widget roles (button, checkbox, switch, tab, slider, menuitem) and composite widgets (tablist, menu, listbox, grid), live region roles (status, alert, log) and window roles (dialog, alertdialog). Most HTML elements already have an implicit role, which is why you rarely write roles at all. The mental model for states: the ARIA attribute is the single source of truth. Script flips the attribute; CSS styles off the attribute; assistive tech reads the attribute. Nothing can drift out of sync.

A disclosure, a toggle and the current page
CSS
.disclosure { padding: 6px 12px; border: 1px solid rgb(148, 163, 184); border-radius: 6px; background: #fff; font: inherit; }
.disclosure[aria-expanded="false"] + .panel { display: none; }
.disclosure[aria-expanded="true"]::after { content: " ▲" / ""; }
.disclosure[aria-expanded="false"]::after { content: " ▼" / ""; }
.panel { margin: 6px 0 12px; padding: 8px; background: rgb(241, 245, 249); }
.toggle { padding: 6px 12px; border: 1px solid rgb(148, 163, 184); border-radius: 6px; background: #fff; font: inherit; font-weight: 700; }
.toggle[aria-pressed="true"] { background: rgb(30, 64, 175); color: #fff; }
nav a { margin-right: 12px; color: rgb(29, 78, 216); }
nav a[aria-current="page"] { color: rgb(30, 41, 59); font-weight: 700; text-decoration: none; border-bottom: 3px solid rgb(29, 78, 216); }
HTML
<button class="disclosure" aria-expanded="false" aria-controls="ship">Shipping details</button>
<div class="panel" id="ship">Ships in 2–3 days.</div>
<p><button class="toggle" aria-pressed="true">B</button> <span id="tip">bold is on</span></p>
<nav aria-label="Account"><a href="#p" aria-current="page">Profile</a><a href="#o">Orders</a></nav>
disclosure.js
function toggleDisclosure(button) {
  const open = button.getAttribute("aria-expanded") === "true";
  button.setAttribute("aria-expanded", String(!open)); // CSS shows or hides the panel
}
Resultlive, in your browser
What’s happening
  1. The disclosure is a real <button> with aria-expanded="false". Chrome reports role button, name "Shipping details", and the ariaExpanded property (ARIA attributes are reflected as DOM properties) reads "false". A screen reader says something like "Shipping details, button, collapsed".
  2. The CSS rule .disclosure[aria-expanded="false"] + .panel { display: none; } hides the panel because of the ARIA state, so the panel's display is none. There's no separate is-open class to forget to update. The ▼/▲ arrows come from ::after with content: " ▼" / "": the / "" gives the arrow empty alternative text, so the name stays "Shipping details" instead of including the triangle (see Accessible names).
  3. toggleDisclosure flips one attribute. After the test calls it, ariaExpanded is "true" and the panel's display is block. The visual state and the announced state can't disagree, because they're the same attribute. aria-controls points at the panel's id; screen-reader support for it is uneven, so it's a hint, not a requirement.
  4. The "B" toggle uses aria-pressed="true": still role button (name "B"; a real one would need aria-label="Bold"), announced as a toggle button, pressed. Styling keys off the state, giving the rgb(30, 64, 175) background. Don't also change the label to "Unbold": the label says what it is, the state says whether it's on.
  5. The <nav> already has the implicit role navigation; aria-label="Account" names it so it can be told apart from other navigation landmarks. aria-current="page" on the link marks the page you're on: still role link, state page, styled with the 3px underline. It's the right attribute for breadcrumbs and menus instead of a .active class.
AdvancedExplicit roles replace native semantics

Roles replace native semantics

A role attribute overrides the element's implicit role. That's useful on a <div> and harmful on anything that already meant something:

An explicit role wins
CSS
h3, ul { margin: 0 0 8px; }
HTML
<h3 id="h" role="button">Settings</h3>
<ul id="l" role="presentation"><li id="li">One</li></ul>
<section id="s1">Unnamed section</section>
<section id="s2" aria-label="Latest news">Named section</section>
<span id="span" role="heading" aria-level="2">Not a real h2</span>
Resultlive, in your browser
What’s happening
  1. <h3 role="button"> computes to role button: the heading is gone from the tree, so it disappears from the screen reader's heading list, which is how many users skim a page. Rule 2 of ARIA: wrap instead, <h3><button>…</button></h3>.
  2. <ul role="presentation"> computes to none: the list semantics are removed (useful only for a list used purely for layout). Its li children lose their meaning too.
  3. A <section> with no accessible name computes to generic; it becomes a region landmark only once it has a name ("Latest news"). That's why <section> alone does nothing for navigation (see Landmarks).
  4. <span role="heading" aria-level="2"> becomes a heading, level 2. It works, but <h2> is shorter and can't be mis-styled into ambiguity; prefer the element.
AdvancedCommon ARIA states and where they go

Common states and where they belong

AttributeUsed onValuesNative alternative
aria-expandedThe button that opens a disclosure, menu, comboboxtrue / false<details> handles it internally
aria-pressedToggle buttonstrue / false / mixednone (a checkbox is a different control)
aria-checkedcheckbox, radio, switch, menuitemcheckbox rolestrue / false / mixed<input type="checkbox">'s checked
aria-selectedtab, option, gridcelltrue / false<option selected>
aria-currentThe current item in a set: page, step, datepage, step, location, date, time, truenone
aria-disabledControls that must stay focusable while unavailabletrue / falsedisabled (removes it from focus order)
aria-invalidForm fields with an errortrue / false / grammar / spellingnone (:invalid isn't exposed)
aria-hiddenDecorative or duplicate contenttruehidden hides it from everyone

#Accessible names and descriptions

Added

Every interactive element needs an accessible name: the text a screen reader reads ("Save, button"), voice control users speak ("click Save") and automated tests query by. A description is extra, secondary information read after the name (a hint, a format, an error). Both are computed by the browser using the W3C Accessible Name and Description Computation (accname), which checks sources in a fixed order and uses the first one that gives text.

The order, simplified: aria-labelledby (text of the referenced elements, even hidden ones) → aria-label → the native label (<label> for inputs, alt for images, <caption> for tables, <legend> for fieldsets) → the element's own text content, for roles that take their name from content (buttons, links, headings, cells) → title and, for inputs, placeholder as a last resort. The mental model: a list of name tags, checked top-down; the first tag that has writing on it wins, and the others are ignored.

One computation, many sources
CSS
.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.row { margin-bottom: 6px; }
.arrow::after { content: " →"; }
.arrow-quiet::after { content: " →" / ""; }
HTML
<div class="row"><button id="text">Save draft</button></div>
<div class="row"><button id="label" aria-label="Close dialog">×</button></div>
<div class="row"><button id="hidden"><svg aria-hidden="true" width="14" height="14"><circle cx="7" cy="7" r="6"/></svg><span class="vh">Delete message</span></button></div>
<div class="row"><button id="empty"><svg width="14" height="14"><circle cx="7" cy="7" r="6"/></svg></button></div>
<div class="row"><label for="email">Email address</label> <input id="email" type="email"></div>
<div class="row"><label>Phone <input id="wrapped" type="tel"></label></div>
<div class="row"><input id="ph" placeholder="Search products"></div>
<div class="row"><span id="billing">Billing</span> <span id="addr">address</span> <input id="lb" aria-labelledby="billing addr" aria-label="ignored"></div>
<div class="row"><label for="both">Visible label</label> <input id="both" aria-label="Different name"></div>
<div class="row"><img id="img" alt="Company logo" width="20" height="20" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Crect width='20' height='20' fill='%23334155'/%3E%3C/svg%3E"></div>
<div class="row"><a id="link" href="#p">Read more<span class="vh"> about pricing</span></a></div>
<div class="row"><button id="gen" class="arrow">Next</button> <button id="gen2" class="arrow-quiet">Next</button></div>
<div class="row"><div id="gdiv" aria-label="Order status">Shipped</div></div>
Resultlive, in your browser
What’s happening
  1. A button with text takes its name from its content: "Save draft". This is the best case: what you see is what's announced.
  2. aria-label="Close dialog" on a "×" button is checked before content, so the name is "Close dialog", not "times" or "multiplication sign".
  3. An icon button with an aria-hidden SVG and visually hidden text is named "Delete message": hidden-from-the-eye text still counts, hidden-from-the-tree content doesn't. The icon-only button without either gets the name "", one of WebAIM's top six errors ("empty buttons", 30.6% of home pages).
  4. Both label patterns work: <label for="email"> gives "Email address", a wrapping <label> gives "Phone". The placeholder-only input is named "Search products" by Chrome as a last resort, but don't rely on it: the placeholder disappears when you type, other tools compute names differently (Testing Library's computation ignores it, see Testing accessibility), and it's usually low contrast.
  5. aria-labelledby="billing addr" concatenates the referenced elements' text in order, "Billing address", and beats the aria-label on the same element. Its trap is visible on the next row: aria-label="Different name" beats the visible "Visible label", so a voice-control user who says "click Visible label" gets nothing. That breaks 2.5.3 Label in Name (A): the name must contain the visible text.
  6. The image's name comes from alt ("Company logo", role image); the link's from its content including hidden text, "Read more about pricing", which fixes vague link text for screen readers while sighted users see "Read more". CSS generated content counts too: a ::after { content: " →" } makes the name "Next →", which a screen reader may read as "Next rightwards arrow". The CSS alternative-text syntax content: " →" / "" keeps the arrow visible but gives it an empty alternative, and the name is back to "Next".
  7. Descriptions (aria-describedby) aren't exposed to page scripts in Chrome (computedDescription is undefined), so this site can't test them directly; they're shown in Accessible forms and should be checked with a screen reader or DevTools' Accessibility pane.
AdvancedRules of thumb for good names

Rules of thumb for names

  • Prefer visible text. Content, <label> and alt are visible (or shown when images fail), so sighted users and screen-reader users get the same name. aria-label is invisible, and automatic page translation often skips attributes, so it may stay in the original language.
  • Name the action, not the icon: "Delete message", not "Trash can". Keep names short; put extra detail in a description.
  • Don't repeat the role: a button named "Close button" is announced "Close button, button".
  • aria-label only works on elements whose role allows a name. On a plain <div> or <span> (role generic) ARIA 1.2 prohibits naming. Chrome's computedName still reports one (the demo's last row: role generic, name "Order status"), but assistive tech isn't required to use a name the spec prohibits, so give the element a suitable role or use real, visible text.
  • Alt text describes the image's purpose in context. Decorative images get alt="" (an empty name, ignored by screen readers); a missing alt makes many screen readers read the file name. See Images, figure and media.
  • title is a weak name: it's only shown on mouse hover, never on touch, and isn't reliably read. Use it as nothing more than a tooltip.

#Keyboard access and focus order

Added

Everything a mouse can do must work from the keyboard (WCAG 2.1.1 Keyboard, level A), without traps (2.1.2), in an order that makes sense (2.4.3 Focus Order). Keyboard users press Tab and Shift+Tab to move between focusable elements, Enter to follow links and activate buttons, Space for buttons and checkboxes, and arrow keys inside composite widgets (radio groups, tabs, menus, listboxes). Screen-reader users, switch users and voice-control users all rely on the same focus mechanics.

What's focusable is decided by HTML, adjusted by tabindex. Links with href, buttons, form controls and <summary> are focusable by default. tabindex="0" adds an element to the Tab order at its DOM position. tabindex="-1" makes it focusable from script (el.focus()) but skips it when tabbing: for headings you move focus to after navigation, dialog containers and roving widgets. Positive tabindex (1, 2, 3…) jumps the element ahead of everything else in the page, in numeric order, and is almost always a bug. The mental model: the Tab order is the DOM order of everything with tabindex 0 or a natively focusable element; CSS moves boxes, not the order.

What can receive focus
CSS
.item { display: inline-block; margin: 0 6px 6px 0; padding: 4px 8px; border: 1px solid rgb(148, 163, 184); border-radius: 4px; }
.skip { position: absolute; left: -9999px; }
.skip:focus { left: 12px; top: 12px; background: #fff; padding: 6px 10px; outline: 3px solid rgb(29, 78, 216); }
HTML
<a class="skip" href="#main">Skip to main content</a>
<button class="item" id="b">button</button>
<a class="item" id="a1" href="#x">a with href</a>
<a class="item" id="a2">a without href</a>
<div class="item" id="d0">div</div>
<div class="item" id="dz" tabindex="0">div tabindex=0</div>
<div class="item" id="dm" tabindex="-1">div tabindex=-1</div>
<button class="item" id="dis" disabled>disabled button</button>
<div inert><button class="item" id="inert">inside inert</button></div>
<h2 id="main" tabindex="-1">Main content</h2>
Resultlive, in your browser
What’s happening
  1. focus() succeeds on the button, the link with href, the tabindex="0" div, the tabindex="-1" div and the tabindex="-1" heading. It fails on the <a> without href (it's not a link at all, just a placeholder), the plain div, the disabled button and the button inside inert.
  2. Don't read focusability from the tabIndex property: it reports 0 for the <a> without href, the disabled button and the inert button, none of which can be focused. The HTML spec defines the property's default as 0 for those element types regardless of whether they're focusable.
  3. tabindex="-1" elements (dm, main) can be focused by script but are skipped by Tab. That's how you move focus to the new page heading after client-side navigation, so screen-reader users start reading in the right place.
  4. The skip link sits off-screen at left: -9999px until it receives focus, then moves to left: 12px. It's the first focusable element on the page, so the first Tab shows it, and following it moves focus to #main, letting keyboard users bypass the navigation (2.4.1 Bypass Blocks, A).
  5. inert removes a subtree from both focus and the accessibility tree: the inert button can't be focused and its computed name is "". It's the native way to disable everything behind a modal or an off-canvas menu.
  6. What isn't tested here: the actual Tab sequence. Tests can't press Tab (script key events are untrusted), so the order claims in this topic come from the HTML spec's sequential focus navigation rules; check them in the live result by clicking just above the first box and pressing Tab.
AdvancedVisual order vs DOM order

CSS can put boxes in a different order from the DOM (order, flex-direction: row-reverse, grid placement, absolute positioning). Tab still follows the DOM, so focus jumps around the screen, failing 2.4.3 Focus Order and 1.3.2 Meaningful Sequence:

flex order moves boxes, not focus
CSS
.bar { display: flex; gap: 8px; }
.bar button { width: 90px; padding: 6px 0; font: inherit; }
.bar .first { order: 3; }
HTML
<div class="bar">
  <button class="first">DOM 1st</button>
  <button>DOM 2nd</button>
  <button>DOM 3rd</button>
</div>
Resultlive, in your browser
What’s happening
  1. order: 3 draws the first button at the right end: measured left to right, the buttons read "DOM 2nd", "DOM 3rd", "DOM 1st".
  2. Pressing Tab in the live result focuses "DOM 1st" first (at the far right), then jumps back to the left. Screen readers also read in DOM order.
  3. Fix the DOM order rather than reordering visually. Chrome 137+ also has reading-flow (for example reading-flow: flex-visual) to make focus and reading order follow the visual layout; as of October 2026 Firefox and Safari don't support it (caniuse), so it's an enhancement, not a fix.
AdvancedRoving tabindex for a toolbar

Roving tabindex for composite widgets

A toolbar, tab list or menu is one Tab stop; arrow keys move within it. The standard technique is the roving tabindex: exactly one item has tabindex="0", the rest -1, and arrow keys move both the 0 and the focus.

A toolbar with one Tab stop
CSS
[role="toolbar"] { display: flex; gap: 4px; }
[role="toolbar"] button { padding: 6px 10px; font: inherit; }
[role="toolbar"] button:focus-visible { outline: 3px solid rgb(29, 78, 216); outline-offset: 1px; }
HTML
<div role="toolbar" aria-label="Text formatting">
  <button>Bold</button><button>Italic</button><button>Underline</button>
</div>
roving.js
function rovingToolbar(toolbar) {
  const items = [...toolbar.querySelectorAll("button")];
  items.forEach((b, i) => (b.tabIndex = i === 0 ? 0 : -1));
  toolbar.addEventListener("keydown", (e) => {
    const i = items.indexOf(document.activeElement);
    if (i < 0) return;
    const keys = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: items.length - 1 };
    if (!(e.key in keys)) return;
    e.preventDefault();
    const next = (keys[e.key] + items.length) % items.length;
    items[i].tabIndex = -1;
    items[next].tabIndex = 0;
    items[next].focus();
  });
}
Resultlive, in your browser
What’s happening
  1. After rovingToolbar runs, the tab indexes are [0, -1, -1]: Tab enters the toolbar once, on "Bold", and the next Tab leaves it. A toolbar with 20 buttons costs one Tab press, not 20.
  2. ArrowRight moves focus to "Italic" and moves the 0 with it: [-1, 0, -1]. If the user tabs away and comes back, they return to the last item they used.
  3. End jumps to "Underline" ([-1, -1, 0]); ArrowRight from the end wraps to "Bold"; ArrowLeft from "Bold" wraps to "Underline". The keys follow the ARIA Authoring Practices Guide's toolbar pattern.
  4. e.preventDefault() stops the arrow keys from also scrolling the page. The test dispatches the key events itself; the listener runs for script events, so the widget logic is proven, while real key presses reach it the same way.
  5. The role="toolbar" with aria-label tells screen readers to expect arrow-key navigation; Chrome computes role toolbar, name "Text formatting".
AdvancedModal focus with <dialog>; positive tabindex

Focus traps: modal dialogs with <dialog>

A modal must keep focus inside it while open (otherwise Tab wanders behind the overlay) and return focus to the control that opened it when closed. A keyboard trap that the user can't escape fails 2.1.2; a modal you can leave with Escape or a close button is the allowed kind. <dialog> with showModal() does all of it natively:

showModal() moves, contains and restores focus
CSS
dialog { padding: 16px; border: 1px solid rgb(148, 163, 184); border-radius: 8px; }
HTML
<button id="open">Edit profile</button>
<dialog id="dlg" aria-labelledby="dlg-title">
  <h2 id="dlg-title">Edit profile</h2>
  <label>Name <input id="name"></label>
  <button id="close">Close</button>
</dialog>
<button id="behind">Behind the dialog</button>
Resultlive, in your browser
What’s happening
  1. Before opening, focus is on "Edit profile". showModal() moves focus into the dialog, to its first focusable element: the "Name" input.
  2. The dialog computes to role dialog and is named "Edit profile" from its heading via aria-labelledby, so screen readers announce what opened.
  3. While it's modal, the rest of the document is inert: calling focus() on "Behind the dialog" leaves focus on the input, and that button's computed name drops to "" because it left the accessibility tree. Tab and Shift+Tab stay inside the dialog (and the browser's own UI); Escape closes it.
  4. After close(), focus returns to "Edit profile", the element that had it before. Hand-rolled modals need a fair amount of JavaScript to do these four things, and they often miss one.
  5. Not tested here: real Tab and Escape presses (untrusted script events don't trigger them). The containment shown is the inertness the HTML spec gives modal dialogs.

#Visible focus: :focus vs :focus-visible

Added

A keyboard user needs to see where focus is, always: WCAG 2.4.7 Focus Visible (AA) requires a visible indicator, and WCAG 2.2 added 2.4.11 Focus Not Obscured (Minimum) (AA: the focused element isn't entirely hidden by sticky headers, cookie banners or chat widgets) and 2.4.13 Focus Appearance (AAA: the indicator is at least as large as a 2px perimeter and has 3:1 contrast between focused and unfocused states). Browsers draw a default focus ring, and the most common accessibility bug in CSS is removing it with outline: none.

:focus matches whenever an element has focus, however it got there, including a mouse click on a button. :focus-visible matches only when the browser's heuristics say a focus indicator should be shown: after keyboard navigation, always for text inputs (you need to see where you're typing), and not after a mouse click on a button. It's Baseline widely available since March 2022, and browsers' own default ring now uses it. The mental model: :focus is "has focus"; :focus-visible is "has focus, and the user is likely to need to see it". Style focus with :focus-visible, so designers stop asking to remove rings after mouse clicks.

A custom focus ring that only shows when it helps
CSS
.btn { padding: 8px 14px; border: 1px solid rgb(148, 163, 184); border-radius: 6px; background: #fff; font: inherit; }
.btn:focus-visible { outline: 3px solid rgb(29, 78, 216); outline-offset: 2px; }
.field { padding: 6px; border: 1px solid rgb(107, 114, 128); border-radius: 4px; font: inherit; }
.field:focus-visible { outline: 3px solid rgb(29, 78, 216); outline-offset: 1px; }
form:focus-within { background: rgb(239, 246, 255); }
form { padding: 8px; }
HTML
<form>
  <label>Coupon <input class="field" id="coupon"></label>
  <button class="btn" type="button" id="apply">Apply</button>
</form>
Resultlive, in your browser
What’s happening
  1. The test focuses "Apply" from script with no earlier mouse or touch interaction. Chrome treats that as a case where focus should be visible: the button matches both :focus and :focus-visible, and the custom ring applies: 3px solid, offset 2px.
  2. The ring colour rgb(29, 78, 216) has a contrast of 6.7:1 against the white page, comfortably above the 3:1 that 1.4.11 Non-text Contrast and 2.4.13 Focus Appearance ask for. The offset keeps the ring from touching the button's own border, so it reads as a separate shape.
  3. form:focus-within matches because a descendant has focus, tinting the form rgb(239, 246, 255). It's useful for highlighting a whole field group or opening a menu while focus is inside it.
  4. focus({ focusVisible: false }) asks the browser not to show an indicator (MDN marks the option experimental; Chrome 155 honours it): the button has :focus but not :focus-visible, so the outline is none. Chrome honoured it for the text input too. Without the option, focusing the input matches :focus-visible and gets its 3px ring.
  5. Not testable here: a real mouse click. In the live result, click "Apply": no ring (it doesn't match :focus-visible). Then press Tab and Shift+Tab: the ring appears. Click into the input: it shows a ring, because text inputs always match.
  6. The input's border, rgb(107, 114, 128), has 4.83:1 against white. Form field boundaries are user-interface components under 1.4.11, so a pale #d1d5db border (1.47:1) would fail.
AdvancedForced colours and sticky headers

Focus rings that survive forced colours

Windows High Contrast / forced colors mode replaces your colours with the user's system palette and removes box-shadow. A focus style made only of box-shadow disappears completely. Keep an outline, even a transparent one, when you use shadows:

focus.css
.btn:focus-visible {
  outline: 2px solid transparent; /* invisible normally, but forced colors paints it */
  box-shadow: 0 0 0 3px rgb(29 78 216 / 0.6);
}

@media (forced-colors: active) {
  .btn:focus-visible {
    outline-color: Highlight; /* a system colour keyword */
  }
}
What’s happening
  1. In normal mode, the transparent outline is invisible and the shadow draws the ring.
  2. In forced colors mode, the browser drops the box-shadow and overrides colours, including the transparent outline, which becomes visible in the system colour. The media query makes it explicit with Highlight.
  3. This can't be shown in the demo frame (forced colors is an OS setting, and Chrome only emulates it from DevTools' Rendering panel: "Emulate CSS media feature forced-colors"). Check it there.

#Colour contrast and not relying on colour alone

Added

Low-contrast text is the most common accessibility failure on the web (83.9% of home pages in WebAIM's 2026 scan). WCAG measures contrast as a ratio between the relative luminance of two colours, from 1:1 (identical) to 21:1 (black on white). 1.4.3 Contrast (Minimum) (AA) requires 4.5:1 for normal text and 3:1 for large text (at least 18pt, which is 24px, or 14pt bold, about 18.66px bold). 1.4.11 Non-text Contrast (AA) requires 3:1 for UI component boundaries (input borders, toggle tracks), focus indicators and meaningful graphics. 1.4.6 Contrast (Enhanced) (AAA) asks for 7:1.

The formula is simple enough to write from memory, and the demo's test does: convert each sRGB channel to linear light (c / 12.92 if c ≤ 0.04045, otherwise ((c + 0.055) / 1.055) ^ 2.4), weight them as L = 0.2126 R + 0.7152 G + 0.0722 B (green dominates because our eyes are most sensitive to it), then ratio = (L_lighter + 0.05) / (L_darker + 0.05). Separately, 1.4.1 Use of Color (A) says colour must never be the only way to convey information: a red border alone doesn't say "error" to someone who can't distinguish red, and links in text need more than colour to stand out.

Measured contrast ratios
CSS
.sw { display: inline-block; vertical-align: top; width: 150px; margin: 0 6px 6px 0; padding: 8px; background: #fff; border: 1px solid rgb(226, 232, 240); }
.a { color: #767676; }
.b { color: #777777; }
.c { color: #949494; font-size: 24px; }
.d { color: #fff; background: #2563eb; }
.e { color: #9ca3af; }
p.body { color: #1f2430; }
p.body a { color: #1d4ed8; }
.no-underline a { text-decoration: none; }
.ok, .bad { display: inline-block; width: 14px; height: 14px; border-radius: 50%; vertical-align: middle; }
.ok { background: #16a34a; } .bad { background: #dc2626; }
HTML
<div class="sw a">#767676 text</div><div class="sw b">#777777 text</div><div class="sw c">#949494 24px</div><div class="sw d">White on #2563eb</div><div class="sw e">#9ca3af text</div>
<p class="body">Read the <a href="#t">terms of sale</a> first.</p>
<p class="body no-underline">Read the <a href="#t">terms of sale</a> first.</p>
<p><span class="ok"></span> API <span class="bad"></span> Database</p>
Resultlive, in your browser
What’s happening
  1. #767676 on white measures 4.54:1: the lightest grey that passes AA for normal text. One step lighter, #777777, measures 4.48:1 and fails. Contrast is a hard threshold; "it looks fine on my monitor" isn't a test.
  2. #949494 measures 3.03:1: it fails for normal text but passes as large text at 24px, where the threshold is 3:1. Bigger, heavier glyphs stay legible at lower contrast.
  3. White on #2563eb (a typical "primary blue" button) measures 5.17:1, a pass. #9ca3af, a common "muted" grey, measures 2.54:1: the exact failure axe-core reports in Testing accessibility.
  4. The link colour #1d4ed8 against the body text #1f2430 is only 2.32:1, so colour alone can't distinguish the link from the words around it (1.4.1 needs 3:1 between them plus a non-colour cue on hover and focus, per WCAG technique G183). The first paragraph keeps the default underline, which passes regardless; the second sets none, which fails.
  5. The status dots differ only by hue: green #16a34a vs red #dc2626 is 1.47:1 between them, so they look nearly identical in greyscale, and to users with red–green colour blindness, the most common kind. The labels "API" and "Database" don't say which is up. Add text ("Down") or a shape (✓ vs ✕).
AdvancedChecking contrast in practice, and APCA

Checking contrast in practice

  • Chrome DevTools shows the ratio in the colour picker (and the AA/AAA lines on the palette) and lists low-contrast text in the CSS Overview panel. axe-core and Lighthouse check text contrast automatically.
  • Contrast over images and gradients isn't a single number; tools often report it as "needs review". Test the worst spot, or put a solid or semi-opaque backdrop behind the text.
  • Placeholder text is text: it needs 4.5:1 too if it carries information. Chrome's default placeholder colour is rgb(117, 117, 117) (#757575), 4.6:1 on white.
  • Disabled controls are exempt from contrast requirements, but users still need to see that they exist.
  • Dark mode needs its own check: a colour that passes on white rarely passes unchanged on a dark background (#1d4ed8 on #1f2430 is about 2.3:1).
Does this button pass?

A button has white text (#fff) on #3b82f6 at 15px, normal weight. It also has a 1px #3b82f6 border on a white page. Which criteria apply and does it pass?

Show answer

The text fails 1.4.3 (3.68:1, below 4.5:1 for normal text). The border is fine, because the button's background itself is the boundary (3.68:1 against white, above 3:1 for 1.4.11).

What’s happening
  1. Text at 15px normal weight isn't large (large starts at 24px, or about 18.66px bold), so the requirement is 4.5:1.
  2. Running the formula: #3b82f6 has a relative luminance of about 0.235, white is 1.0, so (1.0 + 0.05) / (0.235 + 0.05) ≈ 3.68. Fail.
  3. For 1.4.11, the visual boundary of the button (its blue fill against the white page) needs 3:1: 3.68 passes. The border adds nothing because it's the same colour.
  4. Fixes: darken to #2563eb (5.17:1, measured in the demo), or make the text bold at 18.66px or larger, where 3:1 is enough.

#Hiding content: visually hidden vs aria-hidden vs display none

Added

"Hidden" means three different things, and mixing them up is a classic bug. Content can be hidden from everyone (display: none, the hidden attribute, visibility: hidden), hidden from the eye only (a visually hidden class: still read by screen readers), or hidden from assistive tech only (aria-hidden="true": still visible). And some "hiding" hides nothing: opacity: 0 makes an element invisible but it's still in the accessibility tree and still focusable.

The mental model is two audiences, eyes and the accessibility tree, and each technique switches off one, both or neither. Visually hidden text gives screen-reader users context sighted users get from layout ("Delete message 4"); aria-hidden removes decorative or duplicated content (icons next to text); display: none removes content that isn't there for anyone right now (a closed panel).

Six ways to hide, measured
CSS
.visually-hidden {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}
.row { margin-bottom: 6px; }
button { font: inherit; padding: 4px 10px; }
HTML
<div class="row"><button id="vh">Delete<span class="visually-hidden"> message 4</span></button></div>
<div class="row"><button id="ah"><span aria-hidden="true">★ </span>Favourite</button></div>
<div class="row"><button id="dn">Save<span style="display: none"> draft</span></button></div>
<div class="row"><button id="hd">Send<span hidden> now</span></button></div>
<div class="row"><button id="vis" style="visibility: hidden">Invisible</button><button id="op" style="opacity: 0">Transparent</button></div>
<div class="row"><span id="lbl" hidden>Close the banner</span><button id="ref" aria-labelledby="lbl">✕</button></div>
<div class="row"><button id="bad" aria-hidden="true">Hidden but focusable</button></div>
Resultlive, in your browser
What’s happening
  1. The .visually-hidden span adds " message 4" to the button's name, "Delete message 4", while its box is 1 × 1 px and clipped to nothing by clip-path: inset(50%). position: absolute takes it out of the layout, and white-space: nowrap stops the text from wrapping into a tall 1px column, which some screen readers would read word by word. Don't use display: none or width: 0 here: those remove it from the tree too.
  2. aria-hidden="true" on the star keeps it visible but removes it from the name: "Favourite", not "black star Favourite". Use it for decorative icons and for anything that would be read twice.
  3. display: none and the hidden attribute remove content for everyone: the names are just "Save" and "Send". The hidden attribute is simply a UA style of display: none, so CSS that sets display on the element can accidentally un-hide it (see hidden vs display none).
  4. visibility: hidden also removes the element from the tree and from focus (name "", focus() fails). opacity: 0 does neither: "Transparent" is still named, still announced and still focusable, an invisible trap for keyboard users.
  5. The exception that's useful: an element hidden with hidden can still provide a name when referenced by aria-labelledby. The "✕" button is named "Close the banner" from a hidden span. Hidden content is skipped when walking a subtree, but a direct reference is followed.
  6. The last button breaks ARIA rule 4: aria-hidden="true" on something focusable. focus() still lands on it, so a screen-reader user tabs onto an element their screen reader doesn't acknowledge. If something must be hidden from the tree, make it unfocusable too (inert, or tabindex="-1" on its focusable descendants).
AdvancedSix hiding techniques compared
TechniqueVisibleIn the accessibility treeFocusableUse for
display: none / hiddenNoNoNoContent not available right now
visibility: hiddenNo (keeps its space)NoNoHiding while keeping layout, animating out
Visually hidden classNoYesYesExtra context for screen readers, skip links (made visible on focus)
aria-hidden="true"YesNoYes (if focusable: a bug)Decorative icons, duplicated content
opacity: 0NoYesYesOnly as an animation step, never as hiding
inertYes (but unusable)NoNoContent behind a modal, off-screen panels
AdvancedGotcha: old visually-hidden snippets

#Accessible forms and error messages

Added

Forms are where accessibility failures cost money: a screen-reader user who can't tell which field failed, or a keyboard user who can't reach the submit button, abandons the checkout. The rules: every field has a visible label programmatically linked to it (3.3.2 Labels or Instructions; see Labels and accessible names); related controls are grouped with <fieldset> and <legend>; hints and errors are linked to the field so they're read when it gets focus; errors are identified in text (3.3.1 Error Identification, A) with a suggestion for fixing them (3.3.3 Error Suggestion, AA); and fields that collect personal data say what they are with autocomplete (1.3.5 Identify Input Purpose, AA).

The mental model: a sighted user sees the label above the field, the hint under it and the red message beside it, and connects them by position. A screen reader only reads what's programmatically connected: the label through for/id (the name), the hint and error through aria-describedby (the description), the error state through aria-invalid. If it isn't connected, it isn't there.

A field with a hint and an error
CSS
.field { margin-bottom: 12px; }
.field label { display: block; font-weight: 600; }
.field input { padding: 6px; border: 1px solid rgb(107, 114, 128); border-radius: 4px; font: inherit; }
.hint { margin: 2px 0; color: rgb(75, 85, 99); font-size: 14px; }
.error { margin: 2px 0; color: rgb(185, 28, 28); font-size: 14px; }
.error::before { content: "⚠ "; }
.field input[aria-invalid="true"] { border: 2px solid rgb(185, 28, 28); }
.error:empty { display: none; }
HTML
<form id="f" novalidate>
  <div class="field">
    <label for="email">Email</label>
    <p class="hint" id="email-hint">We'll send the receipt here.</p>
    <input id="email" name="email" type="email" autocomplete="email" required aria-describedby="email-hint email-error">
    <p class="error" id="email-error"></p>
  </div>
  <fieldset>
    <legend>Delivery</legend>
    <label><input type="radio" name="d" value="std" checked> Standard</label>
    <label><input type="radio" name="d" value="exp"> Express</label>
  </fieldset>
  <button>Pay</button>
</form>
validate.js
function validate(form) {
  let firstInvalid = null;
  for (const input of form.querySelectorAll("input[required]")) {
    const error = document.getElementById(input.id + "-error");
    const valid = input.checkValidity();
    input.setAttribute("aria-invalid", String(!valid));
    error.textContent = valid ? "" : input.validity.valueMissing
      ? "Enter your email address."
      : "Enter an email address like name@example.com.";
    if (!valid && !firstInvalid) firstInvalid = input;
  }
  firstInvalid?.focus();
  return !firstInvalid;
}
Resultlive, in your browser
What’s happening
  1. The input is a textbox named "Email" by its <label for>. aria-describedby="email-hint email-error" points at both the hint and the (initially empty, so hidden by .error:empty) error paragraph; a screen reader reads the name first, then the description: "Email, edit text, required, We'll send the receipt here."
  2. The radio buttons sit in a <fieldset> that Chrome exposes as a group named "Delivery" by its <legend>, so each option is heard in context: "Delivery, Standard, radio button, checked". Each radio is named by its wrapping label ("Standard", "Express").
  3. Submitting empty: validate uses the browser's own constraint check (checkValidity(), see The constraint validation API), sets aria-invalid="true", writes "Enter your email address." into the error, and moves focus to the field. The test confirms all four. Focus moving to the field makes the screen reader read it again, now with the error in its description.
  4. The error isn't colour-only: it's text, with a "⚠" icon from ::before, and the border gets thicker (2px) as well as red (rgb(185, 28, 28)). That meets 1.4.1 Use of Color and 3.3.1 Error Identification.
  5. With rohit@ the message changes to a suggestion, "Enter an email address like name@example.com.", meeting 3.3.3 Error Suggestion. With a valid address, validate returns true, aria-invalid is "false" and the error empties and hides again.
  6. novalidate turns off the browser's own error bubbles so this code controls the messages, while required and type="email" still drive checkValidity(). autocomplete="email" lets the browser fill it and lets assistive tools identify the field (1.3.5).
  7. What isn't verified here: what a screen reader actually announces (descriptions aren't exposed to page scripts in Chrome). The announcement order above is the typical behaviour; test it with VoiceOver or NVDA.
AdvancedError summaries, required fields, aria-errormessage

Patterns for longer forms

  • Error summary: on submit, show a list of errors at the top (<div role="alert"> or a heading you move focus to), with each item a link to its field (<a href="#email">). It helps on long forms where the first error might be off-screen.
  • Don't disable the submit button until the form is valid. Disabled buttons can't be focused, give no reason, and users can't discover what's missing. Let them submit and explain.
  • Validate on submit, then live: showing errors while the user is still typing their first character is noisy. :user-invalid gives you that timing in CSS (see :invalid vs :user-invalid).
  • Required fields: use the required attribute (it sets the required state for assistive tech) and say "required" in the label or explain the asterisk once at the top. An asterisk alone, especially a red one, relies on users knowing the convention.
  • aria-errormessage is the ARIA attribute made specifically for errors (only read while aria-invalid="true"), but screen-reader support is still incomplete; reports in 2025 and later still show gaps, notably macOS VoiceOver. aria-describedby, as in the demo, is the widely supported choice.
  • Don't break 3.3.7 Redundant Entry (A): don't make users re-type information they've already entered in the same process (offer "same as billing address"); and 3.3.8 Accessible Authentication (AA): don't block paste into password fields or require solving puzzles to log in.

#Live regions: announcing changes

Added

A screen reader reads what the user moves to. When content changes somewhere else on the page (a "Saved" toast, "3 results found", a cart count, an error after an async request), a screen-reader user isn't told unless the change happens inside a live region: an element marked so that assistive technology announces changes to its content. WCAG 4.1.3 Status Messages (AA) requires status messages to be announced without moving focus.

aria-live="polite" announces when the user is idle (finishing the current sentence first); aria-live="assertive" interrupts. The roles bundle the common cases: role="status" is polite (for "Saved", result counts), role="alert" is assertive (for errors and time-critical warnings), role="log" is polite and for appended history (chat). aria-atomic="true" reads the whole region rather than just the changed part (status implies it), and aria-relevant (rarely needed) chooses which kinds of change count. The mental model: a live region is a radio speaker you install on the page in advance. Talking into it works only if the speaker was already there and switched on when you spoke; inserting a new speaker together with the message usually produces silence.

Live regions in the tree
CSS
.toast, .err { min-height: 22px; margin: 6px 0; padding: 4px 8px; border-radius: 4px; }
.toast { background: rgb(220, 252, 231); color: rgb(22, 101, 52); }
.err { background: rgb(254, 226, 226); color: rgb(153, 27, 27); }
.toast:empty, .err:empty { padding: 0; background: none; }
HTML
<button id="save">Save</button>
<div class="toast" role="status" id="status"></div>
<div class="err" role="alert" id="alert"></div>
<div aria-live="polite" id="polite">2 items in cart</div>
<p>Total: <output id="total">$24.00</output></p>
announce.js
// The region exists (empty) from page load; only its text changes.
function announce(region, message) {
  region.textContent = ""; // clear first, so the same message twice is still a change
  setTimeout(() => (region.textContent = message), 100);
}
Resultlive, in your browser
What’s happening
  1. Chrome computes role="status" as status, role="alert" as alert, and the <output> element as status too: <output> is a native live region for calculated results like a total. The plain div reports ariaLive "polite".
  2. The regions are in the HTML from the start, empty. That's the important part: screen readers watch regions that exist when the page loads (or when they're added), and announce changes to them. Injecting <div role="alert">Saved</div> all at once is announced inconsistently across screen readers; role="alert" is the most forgiving, but don't count on it.
  3. announce clears the region, then sets the message 100 ms later. The test confirms the region is empty straight after the call and holds "Draft saved" after the delay. Clearing first matters for repeated messages: setting the same text again is not a change, so "Draft saved" twice in a row would be silent the second time.
  4. Use status for confirmations and counts ("Draft saved", "12 results"), alert only for things that need attention now (errors, session about to expire), because assertive messages interrupt whatever the user was listening to. Keep messages short, and don't put focusable controls inside live regions.
  5. Newer browsers also have ariaNotify(message) on elements and on document, which asks the screen reader to speak a message with no live region at all. Per caniuse in October 2026 it's in Chrome and Edge 141+, Firefox 150+ and Safari 27, so only the latest versions of each; the test confirms it exists in Chrome 155. Feature-detect it (if ("ariaNotify" in document)) and fall back to a live region.
  6. What isn't verified: what any screen reader actually says, or when. Announcement timing and wording differ between VoiceOver, NVDA, JAWS and TalkBack, and live regions are one of the least consistent areas of assistive tech, so test with at least two of them.
AdvancedGotcha: double announcements

#Testing accessibility

Added

Accessibility testing is layered, because no tool sees everything. Automated checkers (axe-core, which powers Lighthouse's accessibility score and most CI integrations; WAVE; Pa11y) parse the DOM and computed styles and catch machine-checkable failures: missing names, missing alt, contrast, invalid ARIA, duplicate ids, missing lang. Deque, axe's maker, reported in 2021 that automated tests found about 57% of issues by volume in its audits (it's a vendor figure, and much lower if you count WCAG criteria instead of issues). The rest needs humans: is the alt text accurate, does the focus order make sense, is the error message helpful, can you complete the checkout with only a keyboard, does it make sense in a screen reader?

The mental model is a spell checker: it finds every misspelt word and none of the sentences that make no sense. Automated tests are a floor you enforce in CI; manual testing with a keyboard and a screen reader is where you find out whether the page actually works. The demo is a small page with seven problems; below it is what axe-core actually reports.

A checkout fragment with seven problems
CSS
.logo { display: block; }
.icon { width: 32px; height: 32px; padding: 0; border: 1px solid rgb(148, 163, 184); background: #fff; }
.muted { color: rgb(156, 163, 175); }
.fake-btn { display: inline-block; padding: 8px 16px; background: rgb(37, 99, 235); color: #fff; border-radius: 6px; cursor: pointer; }
.req { color: rgb(220, 38, 38); }
HTML
<header><img class="logo" width="80" height="24" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='80' height='24'%3E%3Crect width='80' height='24' rx='4' fill='%231d4ed8'/%3E%3C/svg%3E"></header>
<main>
  <h1>Checkout</h1>
  <button class="icon"><svg width="16" height="16" viewBox="0 0 16 16"><path d="M3 3l10 10M13 3L3 13" stroke="currentColor" stroke-width="2"/></svg></button>
  <p><input id="promo" placeholder="Promo code"> <span class="req">*</span></p>
  <p class="muted">Free returns within 30 days.</p>
  <p><a href="#terms">Click here</a> for the terms.</p>
  <div class="fake-btn">Pay now</div>
</main>
Resultlive, in your browser
What’s happening
  1. The logo img has no alt at all, so its computed name is ""; many screen readers then read the image's file name or URL (a data URI here, which would be spoken as gibberish).
  2. The close button contains only an SVG, so its name is "": announced as just "button".
  3. The promo input has no label. Chrome falls back to the placeholder and names it "Promo code", but the label disappears as soon as the user types.
  4. The red asterisk is the only sign the field is required: the input has no required attribute, and the asterisk is plain text with no explanation, conveyed by colour (rgb(220, 38, 38)) and convention alone.
  5. The "Free returns" text is rgb(156, 163, 175) on white: 2.54:1, below 4.5:1.
  6. "Click here" is a valid name, but meaningless when a screen-reader user lists the page's links out of context (2.4.4 Link Purpose (In Context) allows the surrounding sentence to help, but a links list loses it).
  7. "Pay now" is a styled div: role generic, not focusable. A keyboard user can't pay at all.
AdvancedWhat axe-core 4.14 actually reports

What axe-core finds

I ran axe-core 4.14.0 on exactly this markup in headless Chrome 155 (with lang="en" and a <title> added, which the demo frame lacks), with the WCAG 2.0–2.2 A and AA rules plus best practices. Real result:

axe-core 4.14.0 violations
button-name     critical  Buttons must have discernible text                 button
color-contrast  serious   Elements must meet minimum color contrast ratio... .muted
                          "Element has insufficient color contrast of 2.53 (foreground color: #9ca3af,
                          background color: #ffffff, font size: 11.3pt (15px), font weight: normal).
                          Expected contrast ratio of 4.5:1"
image-alt       critical  Images must have alternative text                  img

24 rules passed, including: label, link-name, link-in-text-block, target-size, region, heading-order
What’s happening
  1. axe found three of the seven problems: the empty button, the missing alt and the contrast failure (it computes 2.53:1; the demo's test, which rounds after the same formula, gets 2.54, and both are clearly below 4.5).
  2. The label rule passed for the promo input: axe accepts a placeholder as a name, as Chrome's computation does. A manual review would still flag it.
  3. The red asterisk (colour as the only cue for "required") can't be detected: axe can't know what a red * means.
  4. "Click here" passed link-name: the link has a name; whether it's a meaningful one is a human judgement.
  5. The div "Pay now" produced nothing: it's a valid, contrast-passing div with text. Only a person trying to reach it with Tab finds that the main action of the page is unusable. That's the biggest problem on the page, and the one automation can't see.
AdvancedTesting Library getByRole on the same page

Testing by role in unit and component tests

Testing Library's getByRole queries the page the way assistive tech sees it, by role and accessible name, so a test that finds button named "Pay now" fails if the button is a div. Real results (@testing-library/dom 10.4.2 with jsdom 30.1.3, Node 26) against the same markup; the script I ran wrapped each query in a try/catch and printed the result, condensed here to one line per query:

roles.mjs
const { screen } = await import("@testing-library/dom"); // after jsdom's window and document are global

screen.getAllByRole("button");                              // -> [<button class="icon">] (only one)
screen.getByRole("button", { name: "Pay now" });            // throws
screen.getByRole("textbox", { name: "Promo code" });        // throws
screen.getByRole("textbox").id;                             // -> "promo"
screen.getByRole("link", { name: "Click here" }).getAttribute("href"); // -> "#terms"
What’s happening
  1. Only the icon button has role button, so getByRole("button", { name: "Pay now" }) throws "Unable to find an accessible element with the role "button" and name "Pay now"". A test written this way would have caught the div before review.
  2. The textbox exists, but getByRole("textbox", { name: "Promo code" }) throws: Testing Library's name computation (the dom-accessibility-api package) doesn't use the placeholder, while Chrome does. Tools compute names slightly differently; the robust fix is a real <label>, which every tool agrees on.
  3. Role queries make tests double as accessibility checks, and they don't break when class names or markup structure change. That's why Testing Library recommends getByRole as the default query.
AdvancedKeyboard, screen reader and zoom checks

A practical testing routine

  • In the editor and CI: a linter (eslint-plugin-jsx-a11y for JSX, @angular-eslint/template accessibility rules for Angular templates), role-based queries in component tests, and axe in end-to-end tests (@axe-core/playwright or cypress-axe) failing the build on new violations.
  • Keyboard only: unplug the mouse. Tab through every page: is focus always visible, in a logical order, never trapped (except inside modals), never hidden behind sticky headers? Can you open, use and close every menu and dialog with Enter, Space, Escape and arrows?
  • Screen reader: VoiceOver (macOS and iOS, built in), NVDA (Windows, free), JAWS (Windows, commercial), TalkBack (Android). Use the headings and landmarks lists, fill in a form, trigger an error and a status message.
  • Zoom and reflow: zoom to 200% (1.4.4 Resize Text) and to 400%, which at 1280px is a 320 CSS-pixel-wide viewport (1.4.10 Reflow): no horizontal scrolling, nothing cut off.
  • Preferences: forced colours, prefers-reduced-motion, dark mode and text spacing (1.4.12): all emulated in Chrome DevTools' Rendering panel.
  • DevTools: the Accessibility pane shows the computed role, name, description and properties of any element, and the full accessibility tree view.
Lighthouse says 100

A page scores 100 in Lighthouse's accessibility audit. What can you claim?

Show answer

Only that none of the automated checks Lighthouse runs failed. You can't claim WCAG conformance.

What’s happening
  1. Lighthouse's accessibility score is computed from axe-core rules (a subset), weighted. A score of 100 means every rule it ran passed or didn't apply.
  2. The demo above shows what such rules miss: a div used as the primary button, colour-only required markers, vague link text, and a placeholder used as a label all pass automated checks.
  3. Many WCAG criteria are judgement calls (are alt texts accurate, are headings descriptive, is focus order meaningful, are errors helpful) or need interaction (keyboard traps, focus not obscured).
  4. So: automated checks are necessary, cheap, and catch the most common failures; conformance needs manual keyboard and screen-reader testing on top.

Written fresh for interviews (there was no Google Doc for this one). Every live example was checked in headless Chrome 155 by scripts/check-demos.mjs.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.