Sandbox
The tabs
HTML has no tab element, and radio buttons styled as tabs are still radio buttons to a screen reader. Here, real buttons get the ARIA roles tablist, tab and tabpanel, and a short script handles the keyboard as the APG describes.
- Built with
- tab, tablist and tabpanel
- Browsers
- Chrome 105+, Firefox 121+, Safari 15.4+
- JavaScript
- 32 lines, for the keyboard and the selection
Demo
Turn JavaScript off: the tab list disappears, and the 5 panels show one below the other, each under its heading.
Desk lamp
Description
An adjustable lamp with 3 brightness levels.
Dimensions
45 cm tall, on a base 15 cm across.
Materials
Steel base, linen shade.
Care
A dry cloth is enough. Unplug the lamp before cleaning it.
Delivery
Ships within 48 hours, delivered in 3 to 5 business days.
Anatomy
h3The component heading, which names the tab list.div role="tablist"The tab list, hidden withhiddenuntil the script has run.button role="tab"A tab. Only the selected tab hastabindex="0": it is the only tab stop in the list.div role="tabpanel"The panel of the selected tab, named by its tab. The other panels havehidden="until-found", and theirh4heading only shows without JavaScript.
HTML code
<div class="tabs">
<h3 id="tabs-title">Desk lamp</h3>
<!-- Tab list: hidden until the script has run -->
<div
class="tabs__list"
role="tablist"
aria-labelledby="tabs-title"
hidden>
<!-- Selected tab: the only tab stop in the list -->
<button
id="tab-1"
type="button"
role="tab"
aria-selected="true"
aria-controls="tabpanel-1">
Description
</button>
<button
id="tab-2"
type="button"
role="tab"
aria-selected="false"
aria-controls="tabpanel-2"
tabindex="-1">
Dimensions
</button>
<!-- Tabs 3 to 5: same model -->
</div>
<!-- Panels: with no script, they all show, each under its heading -->
<div id="tabpanel-1" class="tabs__panel">
<h4 class="tabs__title">Description</h4>
<p>…</p>
</div>
<div id="tabpanel-2" class="tabs__panel">
<h4 class="tabs__title">CSS</h4>
<p>…</p>
</div>
</div>
<script>
// Automatic activation: the tab that gets focus shows
const list = document.querySelector('.tabs [role="tablist"]');
const tabs = [...list.querySelectorAll('[role="tab"]')];
const panel = (t) => document.getElementById(t.getAttribute('aria-controls'));
const select = (tab) => {
for (const t of tabs) {
t.setAttribute('aria-selected', t === tab);
t.tabIndex = t === tab ? 0 : -1;
// until-found: Ctrl+F finds the text of a hidden panel.
// aria-hidden removes the empty panel from the accessibility tree.
if (t === tab) {
panel(t).removeAttribute('hidden');
panel(t).removeAttribute('aria-hidden');
panel(t).tabIndex = 0;
} else {
panel(t).setAttribute('hidden', 'until-found');
panel(t).setAttribute('aria-hidden', 'true');
panel(t).removeAttribute('tabindex');
}
}
};
list.addEventListener('click', (e) => {
const tab = e.target.closest('[role="tab"]');
if (tab) select(tab);
});
list.addEventListener('keydown', (e) => {
const i = tabs.indexOf(e.target);
const n = tabs.length;
const keys = { ArrowLeft: i - 1, ArrowRight: i + 1, Home: 0, End: n - 1 };
const next = keys[e.key];
if (next === undefined) return;
e.preventDefault();
const tab = tabs[(next + n) % n];
tab.focus();
select(tab);
});
// Panels: role and name. If Ctrl+F finds text
// in a hidden panel, its tab shows.
for (const t of tabs) {
panel(t).setAttribute('role', 'tabpanel');
panel(t).setAttribute('aria-labelledby', t.id);
panel(t).addEventListener('beforematch', () => select(t));
}
list.hidden = false;
select(tabs[0]);
</script>/* With the script, the tab list replaces the panel headings */
.tabs:has(.tabs__list:not([hidden])) .tabs__title {
display: none;
}
.tabs__list:not([hidden]) {
display: flex;
gap: 4px;
}
/* The selected tab: color and a bar at the top of the tab */
.tabs [aria-selected="true"] {
background: var(--accent);
color: var(--on-accent);
box-shadow: inset 0 4px 0 var(--highlight);
}
.tabs [role="tab"]:focus-visible,
.tabs [role="tabpanel"]:focus-visible {
outline: 3px solid var(--highlight);
}Usage
Use it for
- Content at the same level, where only one panel is useful at a time.
- Short labels that fit on one line.
Avoid it for
- Content to compare: you have to remember the first one to read the second.
- Tabs that change the page address. Those are navigation links, with
aria-current="page"on the active page, and notabrole. - Showing the selected tab with color alone.
- Radio buttons styled as tabs: the screen reader announces radio buttons, and the tab loses its link to its panel.
- A
tabindex="0"on every tab: Tab stops on every tab before reaching the panel.
Accessibility
ARIA roles and attributes
The roles of the list and the tabs are in the HTML, but the list stays hidden with hidden until the script has run. The script adds the tabpanel role to the panels, then keeps aria-selected, tabindex, hidden and aria-hidden up to date.
| Attribute | Element | Description |
|---|---|---|
role="tablist" | div | Groups the tabs. |
aria-labelledby="tabs-title" | div role="tablist" | Gives the list the name of its heading, "Desk lamp". |
role="tab" | button | The button becomes a tab. The screen reader gets its position in the list, "2 of 5". |
aria-selected="true" | button role="tab" | On the displayed tab, and on it alone: the others have aria-selected="false". The script moves it on each tab change. |
aria-controls="tabpanel-1" | button role="tab" | Ties the tab to its panel, through the panel id. |
tabindex="-1" | button role="tab" | On the tabs that are not selected: Tab enters the list only once, on the selected tab. |
role="tabpanel" | div | Added by the script: without JavaScript, the panel stays a plain block under its heading. |
aria-labelledby="tab-1" | div role="tabpanel" | The panel takes the name of its tab. |
tabindex="0" | div role="tabpanel" | On the displayed panel, which holds no link or button: Tab stops there so it can be read. |
hidden="until-found" | div role="tabpanel" | Hides the panels of the tabs that are not selected. The browser's find in page (Ctrl+F) finds their text, fires the beforematch event, and the script shows the tab. Browsers that do not support it treat it as hidden. |
aria-hidden="true" | Hidden div role="tabpanel" | Chrome keeps an until-found panel in the accessibility tree, empty: aria-hidden removes it. |
aria-orientation="vertical" | div role="tablist" | In the Vertical tabs variation: the list is announced as vertical, and you move through it with the up and down arrows. |
Keyboard navigation
| Key | Action |
|---|---|
| Tab | Enters the list, on the selected tab |
| Left arrow or Right arrow | Previous or next tab, in a loop. Its panel shows right away |
| Home or End | First or last tab, shown right away |
| Tab from the list | Moves to the displayed panel |
Tab order
- The selected tab, "Description". The other tabs are reached with the arrow keys.
- The displayed panel, which gets focus so it can be read.
With a screen reader
Example in Chrome with VoiceOver in English. The wording changes from one screen reader to another.
| Action | Announcement |
|---|---|
| Tab to the list | "Description, selected, tab, 1 of 5" |
| Right arrow | "Dimensions, selected, tab, 2 of 5" |
| Tab | The panel name, "Dimensions", then its text |
Related RGAA criteria
The RGAA is the French accessibility standard. Its 106 criteria test WCAG 2.1 level AA.
- Criterion 3.1: On each web page, information must not be conveyed by color alone. Is this rule met?
- The selected tab can be spotted without color, by the bar at the top of the tab.
- Criterion 7.1: Is each script, where needed, compatible with assistive technologies?
- The tabs have the
tabrole, a name and thearia-selectedstate, in a list with thetablistrole. Each panel has thetabpanelrole and the name of its tab. - Criterion 7.3: Can each script be controlled with the keyboard and with any pointing device (except in specific cases)?
- The tabs are controlled from the keyboard, with the arrow keys, Home and End, as the APG describes.
- Criterion 10.7: On each web page, for each element that receives focus, is the focus visible?
- Focus stays visible on the tab and on the panel.
- Criterion 10.8: On each web page, are hidden contents meant to be ignored by assistive technologies?
- Hidden panels are ignored by assistive technologies.
- Criterion 12.8: On each web page, is the tab order consistent?
- The tab order follows the visual order: the selected tab, then its panel.
Other references
WCAG 2.2
EN 301 549
- Accessibility requirements for ICT products and services (V4.1.1, PDF)Clauses 9.1.3.1, 9.1.4.1, 9.2.1.1, 9.2.4.3, 9.2.4.7, 9.4.1.2
APG pattern
Beyond the APG
| New feature | What it adds | Browsers | In the demo |
|---|---|---|---|
hidden="until-found" | The browser's find in page (Ctrl+F) finds the text of a hidden panel, and the script shows its tab. | Chrome 102+, Firefox 148+ | Yes |
focusgroup | Handles the arrow keys and the single tab stop of the tab list in HTML, with no script. | Chrome 150+, Edge 150+ | No |
Variations
Vertical tabs
The list becomes a column, to the left of the panel, and aria-orientation="vertical" announces it. The up and down arrows replace left and right.
Desk lamp
Description
An adjustable lamp with 3 brightness levels.
Dimensions
45 cm tall, on a base 15 cm across.
Materials
Steel base, linen shade.
Care
A dry cloth is enough. Unplug the lamp before cleaning it.
Delivery
Ships within 48 hours, delivered in 3 to 5 business days.
<!-- HTML -->
<div
class="tabs__list"
role="tablist"
aria-labelledby="tabs-title"
aria-orientation="vertical"
hidden>
/* CSS */
/* With the script, the list becomes a column to the left of the panel */
.tabs:has(.tabs__list:not([hidden])) {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
}
.tabs > h3 {
grid-column: 1 / -1;
}
.tabs__list:not([hidden]) {
flex-direction: column;
}
<script>
// Up and down arrows instead of left and right
const keys = { ArrowUp: i - 1, ArrowDown: i + 1, Home: 0, End: n - 1 };
</script>Manual activation
The arrow keys only move focus, and Enter or Space shows the panel. The APG recommends it when a panel takes time to show, for example when it loads from the server.
Desk lamp
Description
An adjustable lamp with 3 brightness levels.
Dimensions
45 cm tall, on a base 15 cm across.
Materials
Steel base, linen shade.
Care
A dry cloth is enough. Unplug the lamp before cleaning it.
Delivery
Ships within 48 hours, delivered in 3 to 5 business days.
The arrow keys move focus: Enter or Space shows the panel.
<script>
// The arrow keys only move focus.
// Enter or Space triggers the tab click, which shows it.
list.addEventListener('keydown', (e) => {
const i = tabs.indexOf(e.target);
const n = tabs.length;
const keys = { ArrowLeft: i - 1, ArrowRight: i + 1, Home: 0, End: n - 1 };
const next = keys[e.key];
if (next === undefined) return;
e.preventDefault();
tabs[(next + n) % n].focus();
});
</script>