Tabs & Menus
Tabs and menus are custom widgets: HTML has no <tabs> or <dropdown-menu> element, so you build them from buttons, ARIA and JavaScript. This is where the rules from the ARIA lessons matter most. The W3C's ARIA Authoring Practices Guide (APG) describes the expected roles and keyboard behaviour for each pattern, and users of screen readers rely on those conventions.
In this lesson you will build a tab interface that follows the APG pattern, learn why most website menus should not use ARIA menu roles, and build a navigation menu with disclosure buttons that meets WCAG's rules for content that appears on hover or focus.
The tabs pattern
A tab interface has three parts:
| Part | Role and attributes |
|---|---|
| The container of tabs | role="tablist" with a name (aria-label or aria-labelledby) |
| Each tab | role="tab", aria-selected="true" or "false", aria-controls pointing at its panel |
| Each panel | role="tabpanel", aria-labelledby pointing at its tab, tabindex="0" if it has no focusable content |
The expected keyboard behaviour:
- Tab moves focus into the tab list, landing on the selected tab. Pressing Tab again moves to the panel, not to the next tab.
- Left and Right arrows move between tabs, wrapping from the last to the first.
- Home and End jump to the first and last tab.
- With automatic activation, a tab's panel is shown as soon as the tab gets focus. This is fine when panels appear instantly; if they load slowly, show the panel only on Enter or Space (manual activation).
Only one tab is in the Tab order at a time: the selected tab has tabindex="0" and the others tabindex="-1". This technique is called a roving tabindex.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Accessible Tabs</title>
<style>
body { font: 1rem/1.5 system-ui, sans-serif; }
[role="tablist"] { display: flex; gap: 4px; border-bottom: 2px solid #0b5cad; }
[role="tab"] { font: inherit; padding: 8px 14px; border: 1px solid #888; border-bottom: 0; background: #f2f6fb; }
[role="tab"][aria-selected="true"] { background: #0b5cad; color: #fff; }
[role="tab"]:focus-visible, [role="tabpanel"]:focus-visible { outline: 3px solid #b00020; outline-offset: 2px; }
[role="tabpanel"] { padding: 12px; border: 1px solid #888; border-top: 0; }
</style>
</head>
<body>
<h1>Lucknow food walk</h1>
<div role="tablist" aria-label="Walk details">
<button type="button" role="tab" id="tab-route" aria-selected="true" aria-controls="panel-route">Route</button>
<button type="button" role="tab" id="tab-food" aria-selected="false" aria-controls="panel-food" tabindex="-1">Food</button>
<button type="button" role="tab" id="tab-price" aria-selected="false" aria-controls="panel-price" tabindex="-1">Price</button>
</div>
<div role="tabpanel" id="panel-route" aria-labelledby="tab-route" tabindex="0">
<p>Start at Aminabad, walk through Chowk and finish near the Rumi Darwaza.</p>
</div>
<div role="tabpanel" id="panel-food" aria-labelledby="tab-food" tabindex="0" hidden>
<p>Kebabs, kulcha nihari, basket chaat and kulfi.</p>
</div>
<div role="tabpanel" id="panel-price" aria-labelledby="tab-price" tabindex="0" hidden>
<p>₹1,499 per person, all food included.</p>
</div>
<script>
const tabs = [...document.querySelectorAll('[role="tab"]')];
function select(tab) {
for (const t of tabs) {
const selected = t === tab;
t.setAttribute("aria-selected", String(selected));
t.tabIndex = selected ? 0 : -1;
document.getElementById(t.getAttribute("aria-controls")).hidden = !selected;
}
tab.focus();
}
for (const tab of tabs) {
tab.addEventListener("click", () => select(tab));
tab.addEventListener("keydown", (event) => {
const i = tabs.indexOf(tab);
let next = null;
if (event.key === "ArrowRight") next = tabs[(i + 1) % tabs.length];
if (event.key === "ArrowLeft") next = tabs[(i - 1 + tabs.length) % tabs.length];
if (event.key === "Home") next = tabs[0];
if (event.key === "End") next = tabs[tabs.length - 1];
if (next) {
event.preventDefault();
select(next);
}
});
}
</script>
</body>
</html>Tab into the tab list, use the arrow keys, then press Tab to move into the panel. A screen reader says something like "Food, tab, selected, 2 of 3". The tabs are <button> elements, so clicking and Enter or Space work without extra code; the script adds only the arrow keys and the state changes.
Tabs or just headings?
Tabs hide content, and hidden content is easy to miss. If users are likely to need all sections, such as the details of a train ticket, a single page with headings is simpler and more accessible. Use tabs when the sections are alternatives the user switches between, such as "Sleeper", "3A" and "2A" fares.
Most website menus are not ARIA menus
ARIA has role="menu", role="menubar" and role="menuitem". They describe application menus, like the File and Edit menus of a desktop program. When a screen reader meets them, it expects arrow-key navigation, typeahead and Esc, and in some screen readers the normal reading keys stop working inside the menu.
A website's main navigation is a list of links, not an application menu. The APG recommends building it as a disclosure navigation menu: a <nav> with a list of links, where each submenu is opened by a <button> with aria-expanded. Users then move with Tab and Enter, just as they do on any other part of the site.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Disclosure Navigation</title>
<style>
nav ul { list-style: none; margin: 0; padding: 0; }
.top { display: flex; gap: 16px; }
.top > li { position: relative; }
.sub { position: absolute; top: 100%; left: 0; background: #fff; border: 1px solid #888; padding: 8px; min-width: 12rem; }
.sub a { display: block; padding: 6px; }
nav button, nav .top > li > a { font: inherit; padding: 6px; min-height: 44px; }
nav button[aria-expanded="true"] { background: #e8eef7; }
</style>
</head>
<body>
<nav aria-label="Main">
<ul class="top">
<li><a href="#">Home</a></li>
<li>
<button type="button" aria-expanded="false" aria-controls="courses-menu">Courses</button>
<ul class="sub" id="courses-menu" hidden>
<li><a href="#">B.Com</a></li>
<li><a href="#">BBA</a></li>
<li><a href="#">B.Sc. Computer Science</a></li>
</ul>
</li>
<li><a href="#">Contact</a></li>
</ul>
</nav>
<main><h1>Jaipur Commerce College</h1><p>Admissions for 2026 are open.</p></main>
<script>
const button = document.querySelector("nav button");
const menu = document.getElementById(button.getAttribute("aria-controls"));
const setOpen = (open) => {
button.setAttribute("aria-expanded", String(open));
menu.hidden = !open;
};
button.addEventListener("click", () => setOpen(menu.hidden));
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !menu.hidden) {
setOpen(false);
button.focus();
}
});
document.addEventListener("click", (event) => {
if (!event.target.closest("nav")) setOpen(false);
});
menu.addEventListener("focusout", (event) => {
if (!event.currentTarget.parentElement.contains(event.relatedTarget)) setOpen(false);
});
</script>
</body>
</html>Tab to "Courses" and press Enter: the submenu opens and the button says "expanded". Tab moves through the course links. Esc closes the submenu and returns focus to the button, clicking elsewhere closes it, and tabbing past the last link closes it too.
Content on hover or focus
Menus that open on mouse hover, and tooltips, must follow 1.4.13 Content on Hover or Focus (AA). Extra content that appears on hover or focus must be:
- Dismissible: the user can close it without moving the pointer or focus, usually with Esc.
- Hoverable: the user can move the pointer onto the new content without it disappearing.
- Persistent: it stays until the user moves away, dismisses it or it is no longer valid.
Opening submenus by click, as in the example, avoids most of these problems. If a design must open them on hover too, add the hover behaviour on top of the button, never instead of it.
When an ARIA menu is right
A toolbar in a web app, such as the "More actions" menu in an email or document editor, is an application menu. Then use the APG menu button pattern:
<button type="button" id="actions-btn" aria-haspopup="menu" aria-expanded="false" aria-controls="actions">
More actions
</button>
<ul role="menu" id="actions" aria-labelledby="actions-btn" hidden>
<li role="menuitem" tabindex="-1">Rename</li>
<li role="menuitem" tabindex="-1">Download as PDF</li>
<li role="menuitem" tabindex="-1">Delete</li>
</ul>
<!-- Script must: move focus to the first item on open, handle Up/Down arrows,
Home/End, Enter to activate, Esc to close and return focus to the button. -->Every one of those keyboard behaviours is your responsibility, because role="menu" promises them.
Common mistakes
role="menu"on site navigation, which changes how screen readers behave and confuses users.- Tabs with every tab in the Tab order and no arrow-key support.
aria-selectedthat never changes, or panels not linked to their tabs.- Hover-only submenus that keyboard and touch users cannot open.
- Submenus that stay open after focus has left them, covering other content.
Key points
- Tabs use
tablist,tabandtabpanelroles,aria-selected,aria-controlsand a rovingtabindex. - Arrow keys move between tabs; Tab moves from the tab list into the panel.
- Website navigation should be links in a
<nav>, with submenus opened by buttons witharia-expanded. - Reserve
role="menu"for application menus and implement the full keyboard pattern. - Content shown on hover or focus must be dismissible, hoverable and persistent (1.4.13).
Exercise
Add a fourth tab, "Booking", to the Try-it tabs example, with a panel that contains a "Book now" link. Because this panel has a focusable element, decide whether the panel still needs tabindex="0", and explain your choice.
Show solution
<button type="button" role="tab" id="tab-book" aria-selected="false" aria-controls="panel-book" tabindex="-1">Booking</button>
<div role="tabpanel" id="panel-book" aria-labelledby="tab-book" hidden>
<p>Walks run every Sunday at 6 pm. <a href="#book">Book now</a></p>
</div>The script picks up the new tab automatically because it collects all [role="tab"] elements. The panel does not need tabindex="0", because the APG pattern adds it only to panels without focusable content; here, Tab from the tab list moves straight to the "Book now" link inside the panel.
Quick Quiz
1. In the tabs pattern, which keys move between tabs?
Arrow keys move between tabs; Tab moves out of the tab list into the panel.
2. What is a roving tabindex?
It keeps one stop in the Tab order for the whole widget, with arrows moving inside it.
3. How should a typical website's main navigation with submenus be built?
The disclosure navigation pattern keeps normal link behaviour and Tab navigation.
4. Which of these does 1.4.13 require for content that appears on hover?
Content on hover or focus must be dismissible, hoverable and persistent.
Learn Web Accessibility with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
