Chapter 9
Accessibility-driven interaction
How JavaScript behavior affects keyboard, screen reader, zoom, and touch users, covering semantics, names, ARIA states, focus, live regions, and roving tabindex, ending in an accessible component playground.
An FAQ page has answers that open when you click the question. Someone using a keyboard presses Tab and skips straight past every question, because each one is a div with a click listener. A screen reader user hears the question text with no hint that it does anything. A pricing section has tabs, and pressing Tab walks through every tab before reaching the content, so getting to the price takes five key presses instead of one. None of this shows up when you test with a mouse.
When JavaScript changes the interface, it also changes the experience for people using a keyboard, a screen reader, zoom, or touch, and for anyone working under stress or distraction. Every chapter so far has included an accessibility check. This chapter puts those checks at the center: you’ll build an accordion, tabs, a dialog, a menu button, and a guided combobox, and write down the contract each one promises.
The mental model
The intro’s third question asks what should update on the screen when state changes, and the answer has always included “the accessibility information that assistive technology reads.” This chapter takes that literally. Every interactive component has a contract with five parts:
- Element or role: what it is (a button, a tab, a dialog).
- Name: what it’s called.
- State: whether it’s expanded, selected, current, or invalid.
- Keyboard: which keys do what.
- Focus: where focus starts, where it moves, and where it goes when something closes.
Most accessibility bugs in scripted components are state bugs. The visual state changes and the accessibility state doesn’t, or the reverse. A panel opens and the button still says it’s collapsed. The second tab is visible and the first tab still says it’s selected. You already know how to prevent this kind of bug: keep the state in one place and render everything from it, including the ARIA attributes.
Semantic HTML first
A <button> is focusable, activates with Enter and Space, is announced as a button, and takes its name from its text, all without script. A div with a click listener has none of that, and adding role="button" only changes what it’s announced as. You’d still have to add tabindex="0" and handle both keys yourself.
ARIA changes what assistive technology is told. It never adds behavior. That’s why the first rule of ARIA is to use a native element when one exists. Reach for ARIA when HTML has no element for what you’re building, such as tabs or a combobox, and you take on the keyboard behavior the role promises.
Accessible names and descriptions
Every interactive element needs a name, and screen readers announce it along with the role. Names come from these sources, in roughly the order you should prefer them:
| Source | Example | Use it for |
|---|---|---|
| The element’s own text | <button>Save</button> | Buttons and links with visible text |
A <label> | <label for="email">Email</label> | Form controls |
aria-labelledby | aria-labelledby="dialog-title" | Naming a region or dialog after a visible heading |
aria-label | <button aria-label="Close">×</button> | Icon buttons with no visible text |
A description is extra information read after the name, and aria-describedby points to it. Chapter 6 used it to connect error messages to fields. Keep names short and put the detail in the description.
You can check any element’s name in the browser’s accessibility tools. In Chrome, select the element in the Elements panel and open the Accessibility pane. Firefox has an Accessibility Inspector panel. Both show the computed name, role, and states, which is what a screen reader receives.
ARIA states come from your state
These four attributes cover most of what scripted components need to report:
| Attribute | Means | Typical use |
|---|---|---|
aria-expanded | This control’s content is shown or hidden | Disclosure buttons, accordions, menu buttons, comboboxes |
aria-selected | This item is the chosen one in a set | Tabs, listbox options |
aria-current | This item matches where the user is | The current page link, the current step, the visible section in a table of contents |
aria-invalid | This field’s value failed validation | Form fields with errors |
Set them as strings ("true" and "false") in your render function, from the same state that drives the visuals. Better still, style from them: [aria-expanded="true"] and [aria-selected="true"] in CSS mean the visual state and the announced state come from the same attribute and can’t drift apart.
Keyboard patterns and focus order
Keyboard users move between components with Tab and Shift+Tab, and within a composite component, such as a set of tabs, a menu, or a listbox, they use the arrow keys. A set of tabs is one Tab stop, and arrows move between the tabs. Without that, a page with several tab sets and menus becomes a long walk through every item.
Focus order follows DOM order. If your layout visually reorders things with CSS, the Tab order won’t match what people see, so keep the source order meaningful. Use tabindex="0" to make a custom element focusable in its natural place, and tabindex="-1" to make something focusable from script only. Don’t use positive values like tabindex="3", because they pull elements out of order for the whole page.
Escape should close the innermost thing that’s open, such as a menu, a listbox, or a dialog, and return focus to the control that opened it. People rely on it as the way out.
When to move focus
Most of the time, leave focus where the user put it, because moving it makes a screen reader user’s reading position jump. Move it when the user’s action takes them somewhere new, or when the element they were on disappears:
| Situation | Where focus goes |
|---|---|
| A modal dialog opens | Into the dialog (showModal() does this) |
| A dialog or menu closes | Back to the button that opened it |
| The focused item is deleted | To a sensible neighbor, such as the next item or the list’s heading |
| A form submits with errors | To the error summary at the top (chapter 6) |
| The user opens a detail view | To the new view’s heading, with tabindex="-1" |
Don’t move focus when content finishes loading, when a status updates, or while someone is typing. Announce those instead.
Focus trapping (keeping Tab inside a container) is only right for modal content. A modal <dialog> already does it, so you don’t need to write one. Non-modal content like a menu or popover shouldn’t trap focus. When focus leaves it, it should close.
Announcing changes with aria-live
When something changes away from where focus is, such as a “Saved” message or a results count, screen reader users won’t know unless it’s announced. A live region is an element whose changes are announced automatically. role="status" is a polite live region: it waits until the screen reader finishes what it’s saying. role="alert" is assertive and interrupts, so save it for urgent problems.
The region must be in the page before you change its text, which is why every example in this book puts an empty <p role="status"> in the HTML and only updates its textContent. Keep messages short, and don’t announce every keystroke.
Toasts, the small messages that appear and disappear on their own, need care. Put them in a live region so they’re announced. Leave them on screen long enough to read, or until dismissed, and never put the only way to do something, such as an “Undo” button, inside a toast that vanishes. Someone who reads slowly or uses magnification may never reach it in time.
Roving tabindex
Roving tabindex is how a composite component becomes a single Tab stop. Exactly one item has tabindex="0" and every other item has tabindex="-1". Tab lands on the one with 0. When the user presses an arrow key, your code moves tabindex="0" to the next item and calls focus() on it. Because the active item keeps tabindex="0", tabbing away and back returns to it. The tabs in this chapter’s project use this pattern, following the ARIA Authoring Practices Guide (APG), which documents the expected keyboard behavior for each widget pattern and is the reference to check your work against.
Motion and touch targets
Animation can make people with vestibular disorders dizzy or sick. Wrap non-essential motion in @media (prefers-reduced-motion: no-preference) so it only runs for people who haven’t asked for less, and use matchMedia from chapter 8 when JavaScript drives the animation. A page-level “reduce motion” toggle saved to localStorage is a useful addition for people who don’t know the operating system setting exists.
Touch targets need room. WCAG 2.2 sets a minimum of 24 by 24 CSS pixels at level AA (with exceptions for targets that have enough spacing around them), and recommends 44 by 44 at level AAA. Small icon buttons are the usual offenders. Padding enlarges the target without changing how the icon looks.
Two smaller patterns come up on most pages. A skip link, <a href="#main">Skip to content</a> as the first focusable element, lets keyboard users jump past repeated navigation. And site navigation that opens and closes should be a disclosure: a button with aria-expanded controlling a list of links. Save role="menu" for application-style action menus.
The smallest working version
A disclosure is a button that shows and hides content. It’s the building block for an accordion, and it shows the whole pattern this book follows: the user needs to reveal extra information without leaving the page, the button’s state updates when they do, and the component remembers one boolean.
<!-- index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Disclosure</title>
<script type="module" src="app.js"></script>
</head>
<body>
<main>
<h1>Shipping</h1>
<div data-disclosure>
<button type="button" aria-expanded="true" aria-controls="returns-panel" data-disclosure-button>
Return policy
</button>
<div id="returns-panel" data-disclosure-panel>
<p>You can return any item within 30 days for a full refund.</p>
</div>
</div>
</main>
</body>
</html>
// app.js
export function initDisclosure(root) {
const button = root.querySelector('[data-disclosure-button]');
const panel = root.querySelector('[data-disclosure-panel]');
const state = { isOpen: false };
function render() {
button.setAttribute('aria-expanded', String(state.isOpen));
panel.hidden = !state.isOpen;
}
button.addEventListener('click', () => {
state.isOpen = !state.isOpen;
render();
});
render();
}
document.querySelectorAll('[data-disclosure]').forEach(initDisclosure);
The HTML starts with the panel visible and aria-expanded="true", so if the script fails, the content is still there and the button tells the truth about it. The script collapses it on startup. aria-controls names the panel the button controls. Screen reader support for it is inconsistent, but it does no harm and documents the relationship for the next developer.
Accessibility check
Test this with the same routine you’ll use for every component in the project:
- Keyboard only. Unplug the mouse or don’t touch it. Tab to the button, press Enter, then Space. Check that focus is always visible and never lands on something you can’t see.
- Screen reader. Use VoiceOver on macOS (Command+F5) or NVDA on Windows. Move to the button and listen for its name, its role, and “collapsed” or “expanded.” Activate it, then move forward and check that the content comes next.
- Accessibility pane. Inspect the button and confirm its name, its role, and that the expanded state flips when you activate it.
- Automated check. Run the axe DevTools extension or the Lighthouse accessibility audit. They catch missing names, low contrast, and invalid ARIA, but they can’t tell whether the keyboard behavior or focus order makes sense, so they come last, after the manual checks.
Here’s a failure that only step 2 catches. Suppose the design puts all the disclosure buttons in a row and all the panels in a box underneath, so the markup lists every button before any panel. With a mouse it works. A screen reader user opens “Return policy,” hears “expanded,” moves forward, and lands on the next button, because the panel is three buttons further down. The fix is to keep each panel directly after its button in the DOM. If the design truly needs a row of buttons with one shared panel area, that’s the tabs pattern, and it should be built as one.
Debugging challenge: the tab that was always selected
Here’s a render function for tabs that a teammate refactored to use a class for styling:
function render() {
tabs.forEach((tab, index) => {
const isSelected = index === state.selectedIndex;
tab.classList.toggle('is-selected', isSelected);
tab.tabIndex = isSelected ? 0 : -1;
panels[index].hidden = !isSelected;
});
}
The symptom: everything looks right, and arrow keys move between tabs and switch panels. But a screen reader user reports that “Monthly” is always announced as selected, even when the yearly prices are showing, and “Yearly” is never announced as selected.
Find it by watching the attributes. Open the Elements panel next to the tabs and press the arrow keys. The panel briefly highlights attributes as they change, and you’ll see class and tabindex change while aria-selected never does. The Accessibility pane for the Monthly tab reports selected as true while the Yearly panel is visible. The refactor replaced the line that set aria-selected, so the value from the starting HTML never changed.
The fix is to render the ARIA state again and use it as the style hook, so the class can’t be updated on its own:
tab.setAttribute('aria-selected', String(isSelected));
[role="tab"][aria-selected="true"] { border-block-end: 3px solid currentColor; }
The state was correct, and the render function skipped one of the places it should show up.
This works because…
This works because each component keeps its state in one variable and renders every representation of that state together: the visible panels, the tabindex values, and the ARIA attributes that screen readers read. Native elements supply the behavior they already have, such as a button’s keyboard support and a modal dialog’s focus handling, so the script only writes the behavior HTML doesn’t provide. Focus moves only when the user’s action takes them somewhere new or removes what they were on, and changes elsewhere on the page are announced through live regions that were in the page from the start.
Project: accessible component playground
Build a page that shows five components, each with a written contract. Design system teams keep this kind of documentation for every component, and writing it is as much of the exercise as the code.
The contract template
Write one of these for each component, under its demo:
Component: Tabs
Elements and roles: buttons with role="tab" inside role="tablist"; panels with role="tabpanel"
Name: tablist named with aria-label; each tab named by its text; each panel by aria-labelledby its tab
States: aria-selected on the active tab; hidden on inactive panels
Keyboard: Tab enters the tablist on the selected tab, then moves to the panel.
Left/Right arrows move and select (wrapping). Home/End go to the first/last tab.
Focus: one Tab stop for the whole tablist (roving tabindex)
Common mistakes: every tab in the Tab order; aria-selected not updated; panel not focusable
Manual test: keyboard routine, screen reader announces "tab, selected, 2 of 3"
Accordion
Build the accordion from the disclosure in the smallest working version, with one disclosure per question, and put each button inside a heading so screen reader users can jump between questions by heading:
<div class="accordion" data-accordion>
<div data-disclosure>
<h3>
<button type="button" id="faq-1-button" aria-expanded="true" aria-controls="faq-1" data-disclosure-button>
How long does shipping take?
</button>
</h3>
<div id="faq-1" role="region" aria-labelledby="faq-1-button" data-disclosure-panel>
<p>Most orders arrive in three to five business days.</p>
</div>
</div>
<!-- more items, each in its own data-disclosure wrapper -->
</div>
Each heading and panel pair has its own data-disclosure wrapper, so initDisclosure runs once per item and each answer opens independently. role="region" makes each open panel a landmark named after its question. The APG suggests leaving it off when there are many panels (more than about six), because too many landmarks make landmark navigation noisy.
For a simple FAQ, <details> and <summary> give you a working disclosure with no JavaScript at all, including keyboard support and expanded state. Document that as the zero-JavaScript option in your contract. The scripted version is worth it when you need the trigger to be a real heading or more control over the open state. Browsers also support a name attribute that groups <details> elements so only one opens at a time. It has worked in all major browsers since late 2024, and older browsers simply let several open at once.
Tabs
<div class="tabs" data-tabs>
<div role="tablist" aria-label="Pricing">
<button type="button" role="tab" id="tab-monthly" aria-controls="panel-monthly" aria-selected="true" data-tab>Monthly</button>
<button type="button" role="tab" id="tab-yearly" aria-controls="panel-yearly" aria-selected="false" tabindex="-1" data-tab>Yearly</button>
</div>
<div role="tabpanel" id="panel-monthly" aria-labelledby="tab-monthly" tabindex="0" data-tab-panel>
<p>$12 per month.</p>
</div>
<div role="tabpanel" id="panel-yearly" aria-labelledby="tab-yearly" tabindex="0" data-tab-panel>
<p>$120 per year.</p>
</div>
</div>
// tabs.js
export function initTabs(root) {
const tabs = [...root.querySelectorAll('[data-tab]')];
const panels = [...root.querySelectorAll('[data-tab-panel]')];
const initial = tabs.findIndex((tab) => tab.getAttribute('aria-selected') === 'true');
const state = { selectedIndex: Math.max(0, initial) };
function render() {
tabs.forEach((tab, index) => {
const isSelected = index === state.selectedIndex;
tab.setAttribute('aria-selected', String(isSelected));
tab.tabIndex = isSelected ? 0 : -1;
panels[index].hidden = !isSelected;
});
}
function select(index) {
state.selectedIndex = (index + tabs.length) % tabs.length;
render();
tabs[state.selectedIndex].focus();
}
root.addEventListener('click', (event) => {
const tab = event.target.closest('[data-tab]');
if (tab) select(tabs.indexOf(tab));
});
root.addEventListener('keydown', (event) => {
if (!event.target.closest('[data-tab]')) return;
const targets = {
ArrowRight: state.selectedIndex + 1,
ArrowLeft: state.selectedIndex - 1,
Home: 0,
End: tabs.length - 1,
};
if (!(event.key in targets)) return;
event.preventDefault();
select(targets[event.key]);
});
render();
}
This uses automatic activation: moving focus to a tab also selects it, which the APG recommends when panels display without a delay. The % tabs.length arithmetic wraps from the last tab to the first and back. Each panel has tabindex="0" so a keyboard user can Tab into it even when it contains no links or buttons. Without JavaScript, every panel is visible under a row of buttons that do nothing. A sturdier version would have the script add the roles too.
Dialog
Reuse the native <dialog> from chapter 8. The contract is short because the browser does the work: showModal() moves focus in, makes the page behind it inert, and closes on Escape. Restore focus explicitly so the behavior doesn’t depend on the browser:
// dialog.js
export function initDialog(root) {
const openButton = root.querySelector('[data-dialog-open]');
const dialog = root.querySelector('[data-dialog]');
openButton.addEventListener('click', () => dialog.showModal());
dialog.addEventListener('close', () => openButton.focus());
}
Menu button
A menu button opens a list of actions, such as Duplicate, Archive, and Delete, the way an application menu does. The role="menu" promise is specific: arrow keys move between items, Escape closes and returns focus to the button, and Tab leaves the menu entirely.
<div class="menu-button" data-menu-root>
<button type="button" id="actions-button" aria-haspopup="menu" aria-expanded="false" aria-controls="actions-menu" data-menu-button>
Actions
</button>
<div role="menu" id="actions-menu" aria-labelledby="actions-button" hidden data-menu>
<button type="button" role="menuitem" tabindex="-1" data-menu-item>Duplicate</button>
<button type="button" role="menuitem" tabindex="-1" data-menu-item>Archive</button>
<button type="button" role="menuitem" tabindex="-1" data-menu-item>Delete</button>
</div>
<p role="status" data-menu-status></p>
</div>
// menu-button.js
export function initMenuButton(root) {
const button = root.querySelector('[data-menu-button]');
const menu = root.querySelector('[data-menu]');
const items = [...menu.querySelectorAll('[data-menu-item]')];
const status = root.querySelector('[data-menu-status]');
const state = { isOpen: false };
function render() {
button.setAttribute('aria-expanded', String(state.isOpen));
menu.hidden = !state.isOpen;
}
function open(focusIndex) {
state.isOpen = true;
render();
items[focusIndex].focus();
}
function close({ returnFocus }) {
state.isOpen = false;
render();
if (returnFocus) button.focus();
}
button.addEventListener('click', () => {
if (state.isOpen) close({ returnFocus: true });
else open(0);
});
button.addEventListener('keydown', (event) => {
if (event.key === 'ArrowDown') { event.preventDefault(); open(0); }
if (event.key === 'ArrowUp') { event.preventDefault(); open(items.length - 1); }
});
menu.addEventListener('keydown', (event) => {
const current = items.indexOf(document.activeElement);
const targets = { ArrowDown: current + 1, ArrowUp: current - 1, Home: 0, End: items.length - 1 };
if (event.key in targets) {
event.preventDefault();
items[(targets[event.key] + items.length) % items.length].focus();
} else if (event.key === 'Escape') {
close({ returnFocus: true });
}
});
menu.addEventListener('click', (event) => {
const item = event.target.closest('[data-menu-item]');
if (!item) return;
status.textContent = `${item.textContent} chosen.`;
close({ returnFocus: true });
});
root.addEventListener('focusout', (event) => {
const leftComponent = event.relatedTarget && !root.contains(event.relatedTarget);
if (state.isOpen && leftComponent) close({ returnFocus: false });
});
document.addEventListener('click', (event) => {
if (state.isOpen && !root.contains(event.target)) close({ returnFocus: false });
});
}
The focusout listener handles Tab. focus and blur don’t bubble, but focusout does, so one listener on the root hears focus leaving any item. event.relatedTarget is the element receiving focus. It’s null when focus goes nowhere in particular, which some browsers also report when you click a button, because they don’t focus buttons on click. So the code ignores null and handles outside clicks with a separate document listener, which keeps a click on the Actions button from closing the menu and instantly reopening it. This is the case for building a menu by hand. A popover can hold the list and give you light dismiss, but it won’t give you the arrow keys or the menu semantics, and for site navigation you’d use a disclosure instead.
Guided example: combobox
A combobox is a text input with a popup list of suggestions. It’s one of the hardest widgets to get right, because focus stays in the input while the user moves through the options, and screen readers handle it inconsistently. Before you build one, check whether a <select> or an <input> with a <datalist> would do the job.
This version teaches the core mechanism. It is not production-complete. Work through it, then compare it with the combobox pattern and examples in the ARIA Authoring Practices Guide.
<label for="country">Country</label>
<input id="country" type="text" role="combobox" autocomplete="off"
aria-autocomplete="list" aria-expanded="false" aria-controls="country-options" data-combobox-input>
<ul id="country-options" role="listbox" aria-label="Countries" hidden data-combobox-listbox></ul>
<p role="status" data-combobox-status></p>
// combobox.js (excerpt: element lookups omitted)
const countries = ['Argentina', 'Australia', 'Austria', 'Belgium', 'Brazil', 'Canada'];
const state = { query: '', isOpen: false, activeIndex: -1 };
function getMatches() {
const query = state.query.trim().toLowerCase();
return countries.filter((name) => name.toLowerCase().startsWith(query));
}
function render() {
const matches = getMatches();
const isOpen = state.isOpen && matches.length > 0;
input.setAttribute('aria-expanded', String(isOpen));
listbox.hidden = !isOpen;
listbox.replaceChildren(...matches.map((name, index) => {
const option = document.createElement('li');
option.id = `country-option-${index}`;
option.setAttribute('role', 'option');
option.setAttribute('aria-selected', String(index === state.activeIndex));
option.dataset.option = name;
option.textContent = name;
return option;
}));
if (isOpen && state.activeIndex >= 0) {
input.setAttribute('aria-activedescendant', `country-option-${state.activeIndex}`);
} else {
input.removeAttribute('aria-activedescendant');
}
status.textContent = isOpen ? `${matches.length} suggestions.` : '';
}
function choose(name) {
input.value = name;
Object.assign(state, { query: name, isOpen: false, activeIndex: -1 });
render();
}
input.addEventListener('input', () => {
Object.assign(state, { query: input.value, isOpen: true, activeIndex: -1 });
render();
});
input.addEventListener('keydown', (event) => {
const matches = getMatches();
if (event.key === 'ArrowDown') {
event.preventDefault();
state.isOpen = true;
state.activeIndex = Math.min(state.activeIndex + 1, matches.length - 1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
state.activeIndex = Math.max(state.activeIndex - 1, 0);
} else if (event.key === 'Enter' && state.isOpen && state.activeIndex >= 0) {
event.preventDefault();
choose(matches[state.activeIndex]);
return;
} else if (event.key === 'Escape') {
Object.assign(state, { isOpen: false, activeIndex: -1 });
} else {
return;
}
render();
});
listbox.addEventListener('mousedown', (event) => event.preventDefault());
listbox.addEventListener('click', (event) => {
const option = event.target.closest('[data-option]');
if (option) choose(option.dataset.option);
});
input.addEventListener('blur', () => {
Object.assign(state, { isOpen: false, activeIndex: -1 });
render();
});
The key idea is aria-activedescendant. Focus never leaves the input, so the user can keep typing, and the attribute tells assistive technology which option is active, as if focus had moved there. Object.assign(state, {...}) updates several state fields in one line. The mousedown handler stops a click on an option from moving focus out of the input, which would otherwise fire blur, close the list, and remove the option before the click arrives.
Write down what this version leaves out: scrolling the active option into view in a long list, Alt+Down Arrow to open without moving, deciding what happens to typed text that matches nothing, debouncing the results announcement, and testing with more than one screen reader and on mobile. That list belongs in the component’s contract under “Known gaps.”
Done means
- The page has five components (accordion, tabs, dialog, menu button, and combobox), each with a written contract using the template.
- The accordion contract mentions
<details>and<summary>as the zero-JavaScript option. - Every component passes the keyboard-only routine, with a visible focus indicator at every step.
- You’ve tested each one with VoiceOver or NVDA and recorded what it announced in the contract.
- The Accessibility pane shows the correct name, role, and state for every interactive element after each interaction.
- Escape closes the menu, the combobox list, and the dialog, and focus returns to the right place each time.
- Tabs and the menu use one Tab stop with arrow keys inside.
- Styles for expanded and selected states use the ARIA attributes as selectors.
- Any animation is wrapped in
prefers-reduced-motion: no-preference, and interactive targets are at least 24 by 24 CSS pixels. - axe reports no violations, and you’ve noted at least one problem it couldn’t catch.
- You’ve written a “This works because…” paragraph for the tabs explaining roving tabindex in your own words.
In the next chapter, you’ll practice finding bugs like the ones in this chapter on purpose, and learn to use AI as a tutor and reviewer without handing over your judgment.
Found an error? Report it.