Accessible Tabs

First posted on March 1, 2026

Tabs, love them (designers do love them) or hate them, they are here to stay. So we might as well make their stay a bit more accessible.

Introduction

Tabs are a UI pattern for which no native HTML element exists, so they must rely on JavaScript.

You can get somewhat close using only HTML and CSS by creatively leveraging (opens in a new window) the <details> element, but (while it is usable) it isn’t really accessible, since it isn’t announced and operated as expected from the Tabs pattern.

The W3C provides guidance (opens in a new window) on the expected behavior and required roles/ARIA attributes. The markup itself isn’t particularly difficult, but the keyboard navigation might pose a bit of a challenge.

What we are trying to achieve is similar to how a named group of native radio-buttons operates.

You press Tab ↹ to reach the group, then use the arrow keys (, , , ) to move between options. If you press Tab ↹ again, focus moves outside the group to the next focusable element.

In short: the group behaves as a single tab-stop because it represents a single control.

Tabs follow the same principle. You Tab ↹ into them, navigate with the arrow keys (limited to and this time), then Tab ↹ again to leave.

However, while this behavior is built-in for radio-buttons, we must recreate it for our custom component, and the way we achieve it is through the roving tabindex (opens in a new window) technique.

But enough preamble, let’s begin!

Laying the Structure

Tab buttons form a list:

<ul>
  <li>
    <button type="button">Tab 1</button>
  </li>
  <li>
    <button type="button">Tab 2</button>
  </li>
  <li>
    <button type="button">Tab 3</button>
  </li>
</ul>

Even though we will need additional roles, it’s always beneficial to start with semantic HTML. It’s more readable than your typical <div> soup, and it lays the foundation for progressive enhancement (which is outside the scope of this article).

Now let’s add the required roles:

<ul role="tablist">
  <li role="none">
    <button type="button" role="tab">Tab 1</button>
  </li>
  <li role="none">
    <button type="button" role="tab">Tab 2</button>
  </li>
  <li role="none">
    <button type="button" role="tab">Tab 3</button>
  </li>
</ul>

You may have noticed role="none" (an alias for role="presentation"). It removes semantics from the <li> because the element that needs to be announced as a Tab is the <button> itself.

Additionally, role="tab" must be directly nested inside role="tablist" to work correctly (similar to how <li> must be nested inside <ul>).

Following the tablist, we define the panels:

<section role="tabpanel">Tabpanel 1</section>
<section role="tabpanel">Tabpanel 2</section>
<section role="tabpanel">Tabpanel 3</section>

Again, we are using semantic HTML (<section> is the element that more closely resembles tabpanel), and with that, our base structure and roles are in place.

Binding the Elements Together

Now we need to connect “Tab 1” to “Tabpanel 1”, and so on. We’ll do this bidirectionally:

<button type="button" id="tab-1" role="tab" aria-controls="tabpanel-1">Tab 1</button>
<!-- ... -->
<section id="tabpanel-1" role="tabpanel" aria-labelledby="tab-1">Tabpanel 1</section>

Labeling the tabpanel by referencing the tab ensures it is properly identified. If you don’t provide an accessible name, it won’t be exposed correctly as a tabpanel.

Referencing the tabpanel via aria-controls on the tab on the other hand doesn’t help as much as one would expect (only JAWS reliably announces it (opens in a new window)). However, it remains useful as a JavaScript hook, and that’s why we are using it anyway.

Completing the Setup

We are just missing a couple more attributes before adding JavaScript:

<button
  type="button"
  id={`tab-${index}`}
  role="tab"
  aria-controls={`tabpanel-${index}`}
  aria-selected={index === 0}
  tabindex={index === 0 ? "0" : "-1"}
>
  Tab {index}
</button>
<!-- ... -->
<section
  id={`tabpanel-${index}`}
  role="tabpanel"
  aria-labelledby={`tab-${index}`}
  hidden={index !== 0}
>
  Tabpanel {index}
</section>

Assuming the first tab is active, we set:

  1. aria-selected="true" on it and "false" on the others, to let the ATs know which tab is active;
  2. tabindex="0" on it and "-1" on the others, to support the implementation of the roving tabindex technique;
  3. hidden on all tabpanel elements except the active one.

Using the hidden attribute achieves the same visual result as display: none, but it encodes the visibility state directly in the markup.

This makes the intent clearer, simplifies JavaScript interaction, and keeps the state declarative rather than imperative.

It also applies the Rule of Least Power (opens in a new window), relying on HTML as much as possible before playing the CSS card.

Making it Functional

First, select the buttons and panels:

const buttons = document.querySelectorAll('[role="tab"]');
const panels = document.querySelectorAll('[role="tabpanel"]');

Next, add a click event listener to each button that:

  1. Sets the correct aria-selected value;
  2. Updates tabindex for roving tabindex;
  3. Shows the corresponding panel and hides the others.
buttons.forEach((button) => {
  button.addEventListener("click", (e) => {
    const clickedButton = e.currentTarget;
    const tabId = clickedButton.getAttribute("aria-controls");

    buttons.forEach((button) => {
      const isActive = button === clickedButton;
      button.setAttribute("aria-selected", String(isActive));
      button.setAttribute("tabindex", isActive ? "0" : "-1");
    });

    panels.forEach((panel) => {
      const isActive = panel.id === tabId;
      panel.hidden = !isActive;
    });
  });
});

Now it’s time for the roving tabindex implementation, which I like to keep as straightforward as possible:

let focusIndex = 0;

buttons.forEach((button) => {
  button.addEventListener("keydown", (e) => {
    if (!["ArrowRight", "ArrowLeft", "Home", "End"].includes(e.key)) return;

    const minIndex = 0;
    const maxIndex = buttons.length - 1;
    // Go to the first or last tab
    if (e.key === "Home") focusIndex = minIndex;
    if (e.key === "End") focusIndex = maxIndex;
    // Move to the next or previous tab
    if (e.key === "ArrowRight") focusIndex++;
    if (e.key === "ArrowLeft") focusIndex--;
    // Loop around the tabs
    if (focusIndex < minIndex) focusIndex = maxIndex;
    if (focusIndex > maxIndex) focusIndex = minIndex;

    buttons[focusIndex].focus();
  });
});

Finally, we need to add this one line to the click handler, to ensure the focus index always starts from the last clicked tab (if there was one):

focusIndex = Array.from(buttons).indexOf(clickedButton);

And with that, we’re done. Until next time, be (and code) well!

Back to top