EduFlow

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.

  1. 1The ARIA Authoring Practices Guide
  2. 2Disclosure: show and hide
  3. 3Tabs
  4. 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:

HTML
<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:

HTML
<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>
JavaScript
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 as aria-label="Festival days").
  • Each tab with role="tab", aria-selected="true" or "false", and aria-controls pointing at its panel.
  • Each panel with role="tabpanel" and aria-labelledby pointing 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.

Three tabs: Friday with tabindex minus one, Saturday selected with tabindex zero, Sunday with tabindex minus one. Arrow keys move between tabs; the Tab key goes from the selected tab down into the Saturday panel. Friday tabindex=-1 Saturday tabindex=0 Sunday tabindex=-1 ← → arrows Tab tabpanel "Saturday" (line-up, set times)
Roving tabindex in Ticketly’s day tabs

Here is Ticketly’s version:

ticketly/lineup.html
<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>
ticketly/tabs.js
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.

Further reading

Check your understanding

4 questions

Question 1 of 4

In a correctly built tab widget, what happens when a keyboard user presses Tab while focus is on the selected tab?

Question 2 of 4

What is wrong with this FAQ toggle?

HTML
<button type="button" aria-controls="refund-answer">How do refunds work?</button>
<div id="refund-answer" hidden>…</div>
Question 3 of 4

Ticketly’s header has an “Events” dropdown containing six links to event pages. Which pattern fits best?

Question 4 of 4

Why does the APG tabs pattern give inactive tabs tabindex="-1"?

Practice

Code playground

Build accessible day tabs

Ticketly’s line-up tabs work only with a mouse and say nothing to screen readers. Turn them into the APG tabs pattern:

  • role="tablist" on the container (name it, for example aria-label="Festival days"), role="tab" on each button, role="tabpanel" on each panel.
  • Each tab has aria-controls pointing to its panel; each panel has aria-labelledby pointing back to its tab.
  • aria-selected is "true" on the selected tab and "false" on the others. Friday starts selected.
  • Roving tabindex: the selected tab has tabindex="0", the others -1.
  • Clicking a tab selects it. ArrowRight and ArrowLeft select and focus the next or previous tab, wrapping around at the ends.
<h2>Line-up</h2>
<div class="tabs">
  <button type="button" class="tab active" id="tab-fri" data-panel="panel-fri">Friday</button>
  <button type="button" class="tab" id="tab-sat" data-panel="panel-sat">Saturday</button>
  <button type="button" class="tab" id="tab-sun" data-panel="panel-sun">Sunday</button>
</div>
<div class="panel" id="panel-fri">
  <p>Nour Quartet, 8 pm. The Saltmarsh Band, 10 pm.</p>
</div>
<div class="panel" id="panel-sat" hidden>
  <p>Layla &amp; the Tides, 7 pm. Harbour Brass, 9 pm.</p>
</div>
<div class="panel" id="panel-sun" hidden>
  <p>Family matinee, 3 pm. Closing set by DJ Marwa, 8 pm.</p>
</div>Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Preview

Console

(no output)

Checks

  • One tablist holds three tabs, and there are three tabpanels
  • Each tab controls its panel, and each panel is labelled by its tab
  • Friday starts selected and only its panel is visible
  • Only the selected tab has tabindex 0; the others have -1
  • Clicking Saturday selects it, shows its panel and moves the tabindex
  • Arrow keys select and focus the next or previous tab, wrapping at the ends