• javascript
  • accessibility
  • aria
  • tabs
  • html

Accessible Tabs with HTML, CSS and JavaScript (Keyboard Support Included)

Build tabs that work with mouse, keyboard and screen readers: ARIA roles, arrow-key navigation and roving tabindex. Full code and a live demo.

By Kini3 min read
A tab bar with Overview, Setup and Notes tabs and the Overview panel visible below

Tabs look simple, which is why they are so often built wrong. Click handlers and a hidden class get you a version that works with a mouse. Then a keyboard user arrives, presses Tab, and has to walk through every tab on the way to the content.

Proper tabs behave like a single control. You Tab into the row once, move between tabs with the arrow keys, and Tab again to leave. This tutorial builds that, with the ARIA roles that make a screen reader describe it correctly.

What you will build

Three tabs that switch between three panels. Click works, and so do the Left and Right arrows, plus Home and End. Try it with your keyboard.

The frame is small, so open the demo in its own tab to try it at full size.

The markup

<!-- In the <head>: <link rel="stylesheet" href="/style.css"> -->
<main class="page">
  <h1>Project details</h1>

  <div class="tabs">
    <div class="tabs__list" role="tablist" aria-label="Project details">
      <button class="tabs__tab" role="tab" id="tab-overview" aria-controls="panel-overview" aria-selected="true">Overview</button>
      <button class="tabs__tab" role="tab" id="tab-setup" aria-controls="panel-setup" aria-selected="false" tabindex="-1">Setup</button>
      <button class="tabs__tab" role="tab" id="tab-notes" aria-controls="panel-notes" aria-selected="false" tabindex="-1">Notes</button>
    </div>

    <section class="tabs__panel" role="tabpanel" id="panel-overview" aria-labelledby="tab-overview" tabindex="0">
      <p>A small weather dashboard that reads from a public API and caches the last response.</p>
    </section>

    <section class="tabs__panel" role="tabpanel" id="panel-setup" aria-labelledby="tab-setup" tabindex="0" hidden>
      <p>Clone the repository, run the install command, then start the dev server on port 3000.</p>
    </section>

    <section class="tabs__panel" role="tabpanel" id="panel-notes" aria-labelledby="tab-notes" tabindex="0" hidden>
      <p>The API allows 60 requests per minute, so the app waits 30 seconds between refreshes.</p>
    </section>
  </div>
</main>

<script src="/script.js" defer></script>

The roles tell assistive technology what this is: a tablist containing tab buttons, each pointing at a tabpanel. Two attributes join them together. aria-controls on a tab names the panel it shows, and aria-labelledby on the panel names the tab that labels it.

Only the selected tab has aria-selected="true" and a normal tab stop. The others carry tabindex="-1", which removes them from the Tab order while still letting a script focus them. This is called a roving tabindex, and it is the reason the row behaves like one control.

The panels have tabindex="0" so that a keyboard user who tabs out of the row lands on the content, which matters when a panel contains no focusable element of its own.

The JavaScript

const list = document.querySelector('[role="tablist"]');
const tabs = [...list.querySelectorAll('[role="tab"]')];

function select(tab, { focus = false } = {}) {
  tabs.forEach((item) => {
    const active = item === tab;
    item.setAttribute("aria-selected", String(active));
    item.tabIndex = active ? 0 : -1;
    document.getElementById(item.getAttribute("aria-controls")).hidden = !active;
  });
  if (focus) tab.focus();
}

list.addEventListener("click", (event) => {
  const tab = event.target.closest('[role="tab"]');
  if (tab) select(tab);
});

// Arrow keys move between tabs; only the active tab is in the Tab order.
list.addEventListener("keydown", (event) => {
  const current = tabs.indexOf(document.activeElement);
  if (current === -1) return;

  const last = tabs.length - 1;
  const target = {
    ArrowRight: current === last ? 0 : current + 1,
    ArrowLeft: current === 0 ? last : current - 1,
    Home: 0,
    End: last,
  }[event.key];

  if (target === undefined) return;
  event.preventDefault();
  select(tabs[target], { focus: true });
});

select is the only function that changes anything. For every tab it sets aria-selected, adjusts the tab stop, and shows or hides the matching panel, so the three states always agree.

The keyboard handler builds a small lookup from key name to target index. The modulo-style wrap makes the Right arrow on the last tab jump back to the first. event.preventDefault() stops Home and End from scrolling the page. Any other key returns early, so Tab itself is untouched.

The CSS that matters

:root {
  --ink: #1f1d1a;
  --muted: #6c675f;
  --line: #dfddd6;
  --accent: #2f46c8;
  --paper: #f7f7f5;
}

* {
  box-sizing: border-box;
}

[hidden] {
  display: none !important;
}

body {
  margin: 0;
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
  color: var(--ink);
  background: var(--paper);
}

.page {
  max-width: 600px;
  margin: 0 auto;
  padding: 2rem 1.25rem;
}

.page h1 {
  margin: 0 0 1.25rem;
  font-size: 1.4rem;
}

.tabs__list {
  display: flex;
  gap: 0.25rem;
  overflow-x: auto;
  border-bottom: 1px solid var(--line);
}

.tabs__tab {
  padding: 0.7rem 1rem;
  font: inherit;
  color: var(--muted);
  background: none;
  border: 0;
  border-bottom: 3px solid transparent;
  cursor: pointer;
  white-space: nowrap;
}

.tabs__tab:hover {
  color: var(--ink);
}

.tabs__tab[aria-selected="true"] {
  color: var(--ink);
  font-weight: 600;
  border-bottom-color: var(--accent);
}

.tabs__tab:focus-visible,
.tabs__panel:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: -3px;
}

.tabs__panel {
  padding: 1.25rem 0.25rem;
  line-height: 1.6;
}

.tabs__panel p {
  margin: 0;
}

Two details here are easy to miss. The selected state is styled from the attribute, [aria-selected="true"], so the visual state and the announced state can never drift apart. And the [hidden] rule at the top guarantees that hidden panels stay hidden even if you later give .tabs__panel a display value.

Common problems and how to fix them

Pressing Tab walks through every tab

Every tab has a normal tab stop, so the focus passes through all of them. Set tabindex="-1" on the inactive tabs, and update it whenever the selection changes, as select does.

The panel is still visible after switching

A hidden attribute loses to any CSS that sets display on the same element. Keep the [hidden] { display: none !important; } safeguard in your base styles.

A screen reader says nothing useful about the content

Check that every aria-controls value matches a real id, and that every panel's aria-labelledby points back to its tab. A typo in one id is enough to break the relationship without any visible sign.

The tabs overflow on a phone

A long tab row pushes the page wider than the screen. overflow-x: auto on the list, which the demo uses, makes the row scroll sideways instead. If you have more than four or five tabs, consider whether they should be tabs at all.

The wrong tool for the job

Tabs suit content that visitors compare one piece at a time. If people need to read everything in order, or each section is really a separate page, use headings and links instead. Tabs that hide content people need cost more than they save.

All articles