Skip to content
Contact me

All building blocks

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

  1. h3 The component heading, which names the tab list.
  2. div role="tablist" The tab list, hidden with hidden until the script has run.
  3. button role="tab" A tab. Only the selected tab has tabindex="0": it is the only tab stop in the list.
  4. div role="tabpanel" The panel of the selected tab, named by its tab. The other panels have hidden="until-found", and their h4 heading only shows without JavaScript.

HTML code

HTML
<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>
CSS
/* 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 no tab role.
  • 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.

AttributeElementDescription
role="tablist"divGroups the tabs.
aria-labelledby="tabs-title"div role="tablist"Gives the list the name of its heading, "Desk lamp".
role="tab"buttonThe 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"divAdded 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

KeyAction
TabEnters the list, on the selected tab
Left arrow or Right arrowPrevious or next tab, in a loop. Its panel shows right away
Home or EndFirst or last tab, shown right away
Tab from the listMoves to the displayed panel

Tab order

  1. The selected tab, "Description". The other tabs are reached with the arrow keys.
  2. 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.

ActionAnnouncement
Tab to the list"Description, selected, tab, 1 of 5"
Right arrow"Dimensions, selected, tab, 2 of 5"
TabThe 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 tab role, a name and the aria-selected state, in a list with the tablist role. Each panel has the tabpanel role 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

Beyond the APG

New featureWhat it addsBrowsersIn 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
focusgroupHandles 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.

Code
<!-- 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.

Code
<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>