Skip to content

Accessibility specialists

  • About
  • Services
  • Blog
  • Resources
Email us
  1. Home
  2. Resources
  3. Accessible components library
  4. Tabs

Pages in this section

Components

  • Accordion
  • Breadcrumbs
  • Dialog
  • Disclosure
  • Pagination
  • Tabs
    • Accessibility requirements
    • Test scenarios
  • Text input
  • Textarea

More information

  • Accessibility requirements
  • Compatibility notes
  • Glossary
  • Test steps
  • View the project on GitHub

Tabs

The tabs component allows people to show and hide content by selecting the corresponding tab control.

It's inspired by physical paper folders, where pages of content are grouped in sections and indexed with a protruding labelled tab.

The tabs component is made up of three parts:

  • tablist - a container to hold the tab elements
  • tab - the controls the user interacts with
  • tabpanel - the containers which hold the content

Example

Open this example in a new tab
  • HTML
  • CSS
  • JavaScript
<h1>Tabs</h1>

<tl-tabs>
  <ul>
    <li>
      <a href="#panel-1-heading">Panel 1</a>
    </li>
    <li>
      <a href="#panel-2-heading">Panel 2</a>
    </li>
    <li>
      <a href="#panel-3-heading">Panel 3</a>
    </li>
  </ul>

  <h2 id="panel-1-heading">Panel 1</h2>
  <div>
    <p>
      The content for panel 1, with a focusable element for the
      <a class="demo-link" href="https://tetralogical.com">TetraLogical website</a>.
    </p>
  </div>

  <h2 id="panel-2-heading">Panel 2</h2>
  <div>
    <p>
      Panel 2 content.
    </p>
  </div>

  <h2 id="panel-3-heading">Panel 3</h2>
  <div>
    <p>
      Panel 3 content.
    </p>
  </div>
</tl-tabs>
/* -------------------------------------------------------------------------- */
/* Tabs                                                                       */
/* -------------------------------------------------------------------------- */

tl-tabs {
  --colour-tab-selected-text: var(--tl-colour-primary, #420065);
  --colour-tab-selected-background: var(--tl-colour-page, #ffffff);
  --colour-tab-selected-highlight: var(--tl-colour-accent, #cc0092);
  --colour-tab-unselected-text: var(--tl-colour-text, #222222);
  --colour-tab-unselected-background: var(--tl-colour-surface, #f0f0eb);

  --colour-panel-text: var(--tl-colour-text, #222222);
  --colour-panel-background: var(--tl-colour-page, #ffffff);

  --colour-link-text: var(--tl-colour-text-link, #7f238b);

  --colour-border: var(--tl-colour-border, #666666);

  --colour-focus-highlight: var(--tl-colour-highlight-focus, #ffc000);
  --colour-focus-contrasting: var(--tl-colour-page, #ffffff);

  --border-width: 1px;
}

tl-tabs {
  display: block;
}

/* The row of tabs, shown when the element is wide enough */
tl-tabs [role="tablist"] {
  display: flex;
  border-block-end: var(--border-width) solid var(--colour-border);
}

tl-tabs a[role="tab"] {
  display: inline-flex;
  flex-wrap: wrap;
  align-content: center;
  justify-content: center;
  min-inline-size: 44px;
  min-block-size: 44px;
  padding: 0.5rem 1rem;
  margin-block-end: calc(-1 * var(--border-width));
  color: var(--colour-tab-unselected-text);
  text-underline-offset: 0.2em;
  background-color: var(--colour-tab-unselected-background);
  border: var(--border-width) solid var(--colour-border);
}

tl-tabs a[role="tab"] + a[role="tab"] {
  margin-inline-start: calc(-1 * var(--border-width));
}

tl-tabs a[aria-selected="true"] {
  color: var(--colour-tab-selected-text);
  text-decoration: none;
  background-color: var(--colour-tab-selected-background);
  border-block-start: none;
  border-block-end: none;
  box-shadow: inset 0 3px 0 0 var(--colour-tab-selected-highlight);
}

/* The accordion toggles, shown instead of the tabs when the element is narrow.
   They match the triggers of the accordion component's one at a time variant */
tl-tabs button.tl-tabs__toggle {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  inline-size: 100%;
  min-inline-size: 44px;
  min-block-size: 44px;
  padding: 0.5rem 1rem;
  margin-block-start: calc(-1 * var(--border-width));
  font: inherit;
  color: var(--colour-tab-unselected-text);
  text-align: start;
  cursor: pointer;
  background-color: var(--colour-tab-unselected-background);
  border: var(--border-width) solid var(--colour-border);
}

tl-tabs button.tl-tabs__toggle svg {
  flex: none;
  max-inline-size: 24px;
  max-block-size: 24px;
}

tl-tabs button.tl-tabs__toggle[aria-expanded="true"] {
  background-color: var(--colour-tab-selected-background);
  border-block-start: none;
  border-block-end: none;
  box-shadow: inset 0 2px 0 0 var(--colour-tab-selected-highlight);
}

tl-tabs button.tl-tabs__toggle[aria-expanded="true"] svg {
  transform: rotate(180deg);
}

/* Forced colours drop box shadows and background colours, which takes away the
   accent stripe that marks the selected tab or the open section. Draw it as a
   thicker top border instead, and give the open section a line above its content */
@media (forced-colors: active) {
  tl-tabs a[aria-selected="true"],
  tl-tabs button.tl-tabs__toggle[aria-expanded="true"] {
    border-block-start: 3px solid currentcolor;
  }

  tl-tabs button.tl-tabs__toggle[aria-expanded="true"] {
    border-block-end: var(--border-width) solid currentcolor;
  }
}

/* The content of a section, in either layout */
tl-tabs .tl-tabs__panel {
  padding: 1rem;
  color: var(--colour-panel-text);
  background-color: var(--colour-panel-background);
  border: var(--border-width) solid var(--colour-border);
  border-block-start: none;
}

/* Focus styles */
tl-tabs button.tl-tabs__toggle:focus-visible,
tl-tabs a[role="tab"]:focus-visible {
  outline: 3px solid var(--colour-focus-contrasting);
  box-shadow: inset 0 0 0 3px var(--colour-focus-highlight);
}

/* Demo content */
a.demo-link {
  color: var(--colour-link-text);
}

a.demo-link:focus-visible {
  outline: 3px solid var(--colour-focus-highlight);
  outline-offset: 3px;
  box-shadow: 0 0 0 6px var(--colour-focus-contrasting);
}
/**
 * Tabs
 *
 * The markup is a series of headings, each followed by the content of its
 * section, so it reads as plain sections without JavaScript. This script turns
 * it into a tablist with a tabpanel per section when the element is wider than
 * SWITCH_SIZE, and into an accordion of disclosures, one open at a time, when
 * it is narrower. It switches between the two layouts as the element is
 * resized, keeping the same section selected and moving focus to its control.
 */

// The chevron on each accordion toggle, which the stylesheet turns when the section is open
const CHEVRON =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m4 8 8 8 8-8" /></svg>';

class TLTabs extends HTMLElement {
  // Below this width in pixels, the tabs render as an accordion
  static SWITCH_SIZE = 800;
  // Gives each instance without an id a unique name to build its ids from
  static instanceCount = 0;

  connectedCallback() {
    // Read the markup once only, in case the element is moved in the DOM
    if (!this.sections) {
      this.name = this.id || `tabs-${TLTabs.instanceCount++}`;
      this.currentIndex = 0;
      this.sections = this.findHeadings()
        .filter((heading) => heading.nextElementSibling)
        .map((heading) => ({
          label: heading.textContent.trim(),
          content: heading.nextElementSibling,
        }));
      this.resizeObserver = new ResizeObserver(() => this.updateLayout());
    }

    // Leave the markup as it is when there are no sections to work with
    if (!this.sections.length) return;

    this.updateLayout();
    this.resizeObserver.observe(this);
  }

  disconnectedCallback() {
    this.resizeObserver.disconnect();
  }

  // Sections start at the first heading level which appears more than once, or
  // failing that at the first heading level which appears at all
  findHeadings() {
    let fallback = [];
    for (const level of ["h1", "h2", "h3", "h4", "h5", "h6"]) {
      const headings = [...this.querySelectorAll(`:scope > ${level}`)];
      if (headings.length > 1) return headings;
      if (headings.length && !fallback.length) fallback = headings;
    }
    return fallback;
  }

  // Build whichever layout the width calls for, unless it is already showing.
  // When focus was inside the old layout it moves to the same section's control
  // in the new one
  updateLayout() {
    const layout = this.clientWidth > TLTabs.SWITCH_SIZE ? "tabs" : "accordion";
    if (layout === this.layout) return;

    const focusedIndex = this.contains(document.activeElement)
      ? this.focusedSectionIndex()
      : -1;

    this.layout = layout;
    if (layout === "tabs") {
      this.renderTabs();
    } else {
      this.renderAccordion();
    }

    if (focusedIndex !== -1) this.controls[focusedIndex].focus();
  }

  // The section whose control or content has focus, or the selected section
  // when focus is somewhere else inside the element
  focusedSectionIndex() {
    const active = document.activeElement;
    for (let i = 0; i < this.sections.length; i++) {
      if (this.controls[i] === active || this.panels[i].contains(active))
        return i;
    }
    return this.currentIndex;
  }

  renderTabs() {
    const tablist = document.createElement("div");
    tablist.className = "tl-tabs__tablist";
    tablist.setAttribute("role", "tablist");
    tablist.setAttribute("aria-orientation", "horizontal");

    this.controls = this.sections.map((section, index) => {
      const tab = document.createElement("a");
      tab.id = `${this.name}-tab-${index + 1}`;
      tab.href = `#${this.name}-panel-${index + 1}`;
      tab.textContent = section.label;
      tab.setAttribute("role", "tab");
      tab.setAttribute("aria-controls", `${this.name}-panel-${index + 1}`);

      tab.addEventListener("click", (event) => {
        // Select the tab rather than following its link to the panel
        event.preventDefault();
        this.selectTab(index);
      });
      tab.addEventListener("keydown", (event) =>
        this.onTabKeydown(event, index),
      );

      tablist.append(tab);
      return tab;
    });

    this.panels = this.sections.map((section, index) => {
      const panel = document.createElement("div");
      panel.id = `${this.name}-panel-${index + 1}`;
      panel.className = "tl-tabs__panel";
      panel.setAttribute("role", "tabpanel");
      panel.setAttribute("aria-labelledby", `${this.name}-tab-${index + 1}`);
      panel.append(section.content);
      return panel;
    });

    this.replaceChildren(tablist, ...this.panels);
    this.selectTab(this.currentIndex);
  }

  // Show one panel and mark its tab as selected. Only the selected tab is in
  // the tab order; the others are reached with the arrow keys
  selectTab(index) {
    this.currentIndex = index;
    this.controls.forEach((tab, i) => {
      if (i === index) {
        tab.setAttribute("aria-selected", "true");
        tab.removeAttribute("tabindex");
      } else {
        tab.removeAttribute("aria-selected");
        tab.setAttribute("tabindex", "-1");
      }
      this.panels[i].hidden = i !== index;
    });
  }

  // Left and Right move between the tabs, wrapping at either end, and Home and
  // End jump to the first and last. The tab which receives focus is selected
  onTabKeydown(event, index) {
    const last = this.sections.length - 1;
    let next;

    switch (event.key) {
      case "ArrowLeft":
        next = index === 0 ? last : index - 1;
        break;
      case "ArrowRight":
        next = index === last ? 0 : index + 1;
        break;
      case "Home":
        next = 0;
        break;
      case "End":
        next = last;
        break;
      default:
        return;
    }

    event.preventDefault();
    this.selectTab(next);
    this.controls[next].focus();
  }

  renderAccordion() {
    const disclosures = [];
    this.controls = [];
    this.panels = [];

    this.sections.forEach((section, index) => {
      const disclosure = document.createElement("div");
      disclosure.className = "tl-tabs__disclosure";

      const toggle = document.createElement("button");
      toggle.type = "button";
      toggle.className = "tl-tabs__toggle";
      toggle.setAttribute("aria-controls", `${this.name}-panel-${index + 1}`);
      // The chevron sits before the label, as it does in the accordion component
      toggle.insertAdjacentHTML("afterbegin", CHEVRON);
      toggle.append(section.label);
      toggle.addEventListener("click", () => this.toggleSection(index));

      const panel = document.createElement("div");
      panel.id = `${this.name}-panel-${index + 1}`;
      panel.className = "tl-tabs__panel";
      panel.append(section.content);

      disclosure.append(toggle, panel);
      disclosures.push(disclosure);
      this.controls.push(toggle);
      this.panels.push(panel);
    });

    this.replaceChildren(...disclosures);
    this.openSection(this.currentIndex);
  }

  // Open the section whose toggle was pressed and close the rest. Pressing the
  // toggle of the open section closes it, leaving every section closed
  toggleSection(index) {
    const open = this.controls[index].getAttribute("aria-expanded") === "true";
    this.openSection(open ? null : index);
  }

  // Open one section and close the others, or close them all when given null.
  // The tab layout always has a tab selected, so with every section closed it
  // falls back to the first one
  openSection(index) {
    this.currentIndex = index ?? 0;
    this.controls.forEach((toggle, i) => {
      toggle.setAttribute("aria-expanded", i === index ? "true" : "false");
      this.panels[i].hidden = i !== index;
    });
  }
}

if (!customElements.get("tl-tabs")) {
  customElements.define("tl-tabs", TLTabs);
}

What problems it solves

The tabs component helps people find, read, and understand related content by breaking it down into more manageable chunks to minimise cognitive load. It allows to toggle between sections rather than scrolling large amounts of content, which can help prevent people from becoming overwhelmed or frustrated.

When to use it

Use the tabs component when:

  • The content can be separated into clearly labelled sections
  • The first section is more relevant than the others for most users
  • Users do not need to view more than one section at once

When not to use it

Do not use the tabs component for:

  • Unrelated sections of content (use separate pages instead)
  • Important information; some people may miss it
  • Content that needs to accessed in a specific order, such as a step-by-step process
  • Blocks of information that users may need to compare; having to memorise the information while switching in between tabs can be frustrating and difficult

Where to use it

Tabs are often found on product pages, dashboards, settings screens and profile pages, where a few related views share the same space on the page.

How it works

Above 800 pixels wide, the sections are shown as a row of tabs with one tabpanel visible at a time. Only the selected tab is in the tab order, the arrow keys move between the tabs, and the tabpanel itself is not focusable, so pressing Tab from the selected tab moves to the first focusable element in its content, or out of the component when there is none.

Below that width the sections become an accordion which keeps one section open at a time, matching the one at a time variant of the accordion component: each toggle is a button with an aria-expanded state and a chevron before its label, and activating the open section's toggle collapses it. The layout switches as the element is resized, keeping the same section selected.

Without JavaScript

The markup is a list of in-page links, followed by a heading and the content for each section. Without the script, people get a table of contents as a list of links, and can read every section in full. The script replaces that markup with the tabs or the accordion, so nobody sees a control which is broken, and no content is unreachable.

Assistive technology compatibility

This component has been tested with the following browsers and assistive technologies.

Assistive technology Result Notes
Keyboard No issues No notes
Voice Access/Chrome 154.0.8/Windows 11 No issues No notes
JAWS 2026.2603.86/Chrome 154.0.8/Windows 11 No issues No notes
NVDA 2026.1.1/Firefox 157.0/Windows 11 No issues No notes
VoiceOver/Safari macOS Tahoe 26.6 No issues No notes
VoiceOver/Safari/iOS 26.0 No issues No notes
TalkBack/Chrome 154.0.8/Android 17 No issues No notes
Windows Contrast themes No issues No notes
Windows Magnifier and/or Apple Zoom No issues No notes
Browser zoom No issues No notes
prefers-reduced-motion Not applicable There are no animations to reduce

Last updated: 13 October 2026.

Back to top

We like to listen

Wherever you are in your accessibility journey, get in touch if you have a project or idea.

hello@tetralogical.com
Colour scheme

60 Windsor Avenue, London, SW19 2RR, UK +44 (0)20 8895 6768 hello@tetralogical.com

© TetraLogical Services Ltd. All Rights Reserved. Company number 12951928.

Privacy policy • Accessibility statement • Code of ethics