Section 3 · Keyboard, Focus and ARIA
Widget Patterns: Disclosure, Tabs and Menu Button
21 min4 min readCode playground
Lesson at a glance
Build disclosure, tabs and menu button widgets that follow the ARIA Authoring Practices Guide, with the right roles, states and keyboard behaviour, and know when not to.
- 1The ARIA Authoring Practices Guide
- 2Disclosure: show and hide
- 3Tabs
- 4Menu button, and when not to use it
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Ticketly’s line-up section has three tabs, Friday, Saturday and Sunday, built from styled buttons that swap panels on click. With a mouse it is fine. With a screen reader it is a row of three unrelated buttons, nothing says which day is showing, and the panels change silently. There is no <tabs> element in HTML, so this is ARIA’s home ground.
The ARIA Authoring Practices Guide
The ARIA Authoring Practices Guide (APG), published by the W3C, describes about thirty common widget patterns: accordion, combobox, dialog, disclosure, listbox, menu button, tabs, tree and more. For each one it lists the roles, states and properties, and the exact keyboard interaction users expect. Screen reader users learn these conventions across thousands of sites, so following them means your widget behaves the way they already know.
Two cautions. The APG examples are demonstrations, not production components, so test them in your context. And the APG itself says to prefer native HTML where it exists. That rules out a surprising number of custom widgets.
Disclosure: show and hide
The simplest pattern is a button that shows and hides a region, such as Ticketly’s FAQ answers. If the design allows it, native <details> and <summary> give you the whole thing with no script:
<details>
<summary>How do refunds work?</summary>
<p>You can get a full refund up to 14 days before the festival.</p>
</details>When you need a custom version, the essential state is aria-expanded on the button:
<h3>
<button type="button" aria-expanded="false" aria-controls="refunds">How do refunds work?</button>
</h3>
<div id="refunds" hidden>
<p>You can get a full refund up to 14 days before the festival.</p>
</div>button.addEventListener('click', () => {
const open = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', String(!open));
panel.hidden = open;
});The screen reader announces “How do refunds work?, button, collapsed”, then “expanded”. Wrapping the button in a heading keeps the FAQ questions in heading navigation.
Tabs
The tabs pattern has more moving parts:
- A container with
role="tablist"(and a name, such asaria-label="Festival days"). - Each tab with
role="tab",aria-selected="true"or"false", andaria-controlspointing at its panel. - Each panel with
role="tabpanel"andaria-labelledbypointing back at its tab. - Keyboard: Left and Right arrows move between tabs (wrapping at the ends), Home and End jump to the first and last, and Tab moves out of the tablist into the panel.
That last point uses a technique called roving tabindex. Only the selected tab has tabindex="0"; the others have tabindex="-1". The whole tablist is one Tab stop, and the arrow-key handler moves focus and the 0 between tabs.
Here is Ticketly’s version:
<div role="tablist" aria-label="Festival days">
<button type="button" role="tab" id="tab-fri" aria-selected="true" aria-controls="panel-fri">Friday</button>
<button type="button" role="tab" id="tab-sat" aria-selected="false" aria-controls="panel-sat" tabindex="-1">Saturday</button>
<button type="button" role="tab" id="tab-sun" aria-selected="false" aria-controls="panel-sun" tabindex="-1">Sunday</button>
</div>
<div role="tabpanel" id="panel-fri" aria-labelledby="tab-fri" tabindex="0">…</div>
<div role="tabpanel" id="panel-sat" aria-labelledby="tab-sat" tabindex="0" hidden>…</div>
<div role="tabpanel" id="panel-sun" aria-labelledby="tab-sun" tabindex="0" hidden>…</div>const tabs = [...document.querySelectorAll('[role="tab"]')];
function select(tab) {
for (const t of tabs) {
const on = t === tab;
t.setAttribute('aria-selected', String(on));
t.tabIndex = on ? 0 : -1;
document.getElementById(t.getAttribute('aria-controls')).hidden = !on;
}
tab.focus();
}
tabs.forEach((tab) => tab.addEventListener('click', () => select(tab)));
document.querySelector('[role="tablist"]').addEventListener('keydown', (event) => {
const i = tabs.indexOf(event.target);
const moves = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: tabs.length - 1 };
if (!(event.key in moves) || i === -1) return;
event.preventDefault();
select(tabs[(moves[event.key] + tabs.length) % tabs.length]);
});This uses automatic activation: arrowing to a tab shows its panel immediately, which the APG recommends when panels display without delay. If a panel has to load data, use manual activation instead: arrows only move focus, and Enter or Space selects. The panels get tabindex="0" because they contain no focusable elements of their own; that gives Tab somewhere to land.
Menu button, and when not to use it
The APG menu button pattern is a button with aria-haspopup="menu" and aria-expanded that opens an element with role="menu" containing role="menuitem" children. Arrow keys move through the items, Escape closes the menu and returns focus to the button. It is right for application-style action menus, such as Ticketly’s “⋯” menu on a booking with “Download tickets”, “Transfer” and “Cancel booking”.
It is wrong for website navigation. The menu role tells screen readers to expect desktop-application behaviour: they switch interaction modes and announce items as menu items, not links. For a header “Events” dropdown full of links, use a disclosure: a button with aria-expanded that reveals an ordinary list of links. Simpler code, and it behaves the way users of websites expect.
Section 3 has made Ticketly operable. Section 4 turns to what people see and read, starting with colour and contrast.
Key takeaways
- The ARIA Authoring Practices Guide (APG) documents the roles, states and keyboard behaviour users expect from each widget pattern.
- A disclosure is a button with aria-expanded that shows and hides content; native details and summary give you one for free.
- Tabs use tablist, tab and tabpanel roles, aria-selected, a roving tabindex so only the active tab is a Tab stop, and arrow keys to move between tabs.
- Use role=“menu” only for application-style action menus; site navigation dropdowns are disclosures of ordinary links.