Accessibility Developer Kit · Guide 07 / 20

Accessible Tabs

The ARIA tabs pattern with roving tabindex. Covers horizontal and vertical tabs, manual vs automatic activation, and fixes for the common “divs with onClick” anti-pattern — in Vanilla and React.

  • WCAG: 2.1.1 · 2.4.3 · 4.1.2
  • Level: Intermediate
  • Icons: none (text tabs)
1

What we are building

A tablist that follows the ARIA Authoring Practices Guide — the pattern browsers themselves use.

What we are building

Tabs need the roving tabindex pattern: only the active tab is in the Tab sequence; arrow keys move within the tablist; Enter / Space activates. Tab leaves the tablist and lands on the panel. This mirrors how Chrome, Firefox and Safari handle their own browser tabs.

2

The 4 real-world variants

Switch between the inaccessible version and the accessible fix.

The 4 real-world variants

Variant A

Standard horizontal tabs

Preferred
Live example
Profile

Panel content for “profile”. Tab from the active tab lands here.

← → focuses tabs. Enter / Space activates.

HTML · Accessible
<!-- ARIA tabs pattern with roving tabindex (manual activation) -->
<div role="tablist" aria-label="Account settings">
  <button role="tab" id="tab-profile"
          aria-selected="true"
          aria-controls="panel-profile"
          tabindex="0"&gt;Profile</button>
  <button role="tab" id="tab-billing"
          aria-selected="false"
          aria-controls="panel-billing"
          tabindex="-1"&gt;Billing</button>
</div>

<div role="tabpanel"
     id="panel-profile"
     aria-labelledby="tab-profile"
     tabindex="0"&gt;Profile content&lt;/div&gt;
<div role="tabpanel"
     id="panel-billing"
     aria-labelledby="tab-billing"
     tabindex="0"
     hidden&gt;Billing content&lt;/div&gt;

<!-- JS: Arrow keys change focus; Enter/Space activates (manual mode) -->

Manual activation: arrow keys move focus, Enter / Space activates. The ARIA Authoring Practices Guide recommended pattern.

Variant B

Div-tabs (anti-pattern)

Critical fix
Live example
Home

Try Tab-ing to each version. Real tabs are focusable and SR-friendly.

HTML · Accessible
<!-- Roving tabindex: only the active tab is tabbable. -->
<div role="tablist"&gt;
  <button role="tab" aria-selected="true"  tabindex="0"&gt;Tab 1</button>
  <button role="tab" aria-selected="false" tabindex="-1"&gt;Tab 2</button>
  <button role="tab" aria-selected="false" tabindex="-1"&gt;Tab 3</button>
</div>
<!-- Arrow keys move within the tablist; Tab exits to the panel. -->

Divs with onClick have no role, no tabindex, and are invisible to SRs. Switch to real <button> with ARIA tabs pattern.

Variant C

Vertical tabs

Settings UIs
Live example
General

Vertical tabs use ↑ ↓ instead of ← →.

HTML · Accessible
<!-- Vertical tabs: add aria-orientation and switch to Up/Down arrows -->
<div role="tablist"
     aria-orientation="vertical"
     aria-label="Project settings"&gt;
  <button role="tab" aria-selected="true"  tabindex="0"&gt;General</button>
  <button role="tab" aria-selected="false" tabindex="-1"&gt;Security</button>
  <button role="tab" aria-selected="false" tabindex="-1"&gt;API Keys</button>
</div>
<!-- JS: ArrowDown / ArrowUp (not Left/Right) move between tabs -->

Add aria-orientation='vertical' and switch keyboard navigation to Up/Down arrows instead of Left/Right.

Variant D

Automatic activation

Cheap panels only
Live example
Day

Panel changes as soon as you press arrow — no Enter needed.

← → both focuses AND activates. Avoid if panels are expensive to render.

JAVASCRIPT · Accessible
<!-- Manual activation (preferred when panels are expensive) -->
onArrow(() =&gt; {
  focusTab(nextId);             // only focus moves
});
onEnterOrSpace(() =&gt; {
  setActiveTab(focusedId);      // user chooses when to load
});

/* OR, auto-activation when panels are cheap */
onArrow(() =&gt; {
  setActiveTab(nextId);
  focusTab(nextId);
});

Panel changes as you arrow through — good UX when panels are cheap to render, bad if each triggers a network request.

3

Flagship implementation

One AccessibleTabs component for horizontal + vertical, manual + automatic.

Flagship implementation

AccessibleTabs.jsx
import React, { useRef, useState } from "react";
import "./AccessibleTabs.css";

/**
 * AccessibleTabs
 *  - tabs:         [{ id, label, content }]
 *  - defaultId:    initially selected tab id
 *  - label:        aria-label for the tablist
 *  - orientation:  "horizontal" (default) | "vertical"
 *  - activation:   "manual" (default) | "automatic"
 */
export default function AccessibleTabs({
  tabs,
  defaultId,
  label = "Tabs",
  orientation = "horizontal",
  activation = "manual",
}) {
  const [active, setActive] = useState(defaultId ?? tabs[0]?.id);
  const btnRefs = useRef({});

  function activate(id) {
    setActive(id);
  }

  function focusTab(id) {
    btnRefs.current[id]?.focus();
  }

  function onKey(e, index) {
    const nextKey = orientation === "vertical" ? "ArrowDown" : "ArrowRight";
    const prevKey = orientation === "vertical" ? "ArrowUp"   : "ArrowLeft";
    let next = null;
    if (e.key === nextKey) next = (index + 1) % tabs.length;
    else if (e.key === prevKey) next = (index - 1 + tabs.length) % tabs.length;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End")  next = tabs.length - 1;
    if (next === null) return;
    e.preventDefault();
    const id = tabs[next].id;
    focusTab(id);
    if (activation === "automatic") activate(id);
  }

  return (
    <>
      <div
        role="tablist"
        aria-label={label}
        aria-orientation={orientation}
        className={`tabs tabs--${orientation}`}
      >
        {tabs.map((t, i) => {
          const isActive = active === t.id;
          return (
            <button
              key={t.id}
              ref={(el) => (btnRefs.current[t.id] = el)}
              type="button"
              role="tab"
              id={`tab-${t.id}`}
              aria-selected={isActive}
              aria-controls={`panel-${t.id}`}
              tabIndex={isActive ? 0 : -1}
              onClick={() => activate(t.id)}
              onKeyDown={(e) => onKey(e, i)}
              className={`tabs__tab ${isActive ? "is-active" : ""}`}
            >
              {t.label}
            </button>
          );
        })}
      </div>

      {tabs.map((t) => (
        <div
          key={t.id}
          role="tabpanel"
          id={`panel-${t.id}`}
          aria-labelledby={`tab-${t.id}`}
          tabIndex={0}
          hidden={active !== t.id}
          className="tabs__panel"
        >
          {t.content}
        </div>
      ))}
    </>
  );
}
Usage · Demo.jsx
import AccessibleTabs from "./AccessibleTabs";

export default function Demo() {
  const tabs = [
    { id: "profile", label: "Profile", content: <ProfilePanel /> },
    { id: "billing", label: "Billing", content: <BillingPanel /> },
    { id: "notifications", label: "Notifications", content: <NotifPanel /> },
  ];

  return (
    <AccessibleTabs
      tabs={tabs}
      defaultId="profile"
      label="Account settings"
      orientation="horizontal"
      activation="manual"
    />
  );
}
4

Keyboard behavior

Roving tabindex: Tab enters/exits the tablist; arrows move within.

Keyboard behavior

KeyExpected result
TabEnters the tablist (lands on active tab), then exits to the panel
← → (horizontal)Moves focus between tabs
↑ ↓ (vertical)Moves focus between tabs
Enter / SpaceActivates focused tab (manual mode)
Home / EndJumps to first / last tab
5

Screen reader expectations

Tabs are a well-known pattern; SRs announce them verbosely.

Screen reader expectations

  • Tablist entry: “Account settings, tab list, 3 tabs”
  • Active tab: “Profile, tab, selected, 1 of 3”
  • Inactive tab: “Billing, tab, 2 of 3”
  • Panel: “Profile, tab panel”
  • Vertical: “Project settings, tab list, vertical, 4 tabs”
6

WCAG mapping

Why each rule matters here.

WCAG mapping

Success CriterionWhy it matters
2.1.1 Keyboard (A)All tabs must be operable with keyboard — arrow keys and Enter.
2.4.3 Focus Order (A)Tab order must be logical: tablist → active panel → next section.
4.1.2 Name, Role, Value (A)Must expose role=tab + aria-selected + aria-controls.
1.4.11 Non-text Contrast (AA)Selected-tab indicator must have 3:1 contrast.
2.5.5 Target Size (AA, 2.2)Tabs must be at least 24×24 (prefer 44×44).
7

Testing approach

Framework-agnostic patterns. Pick the one that matches your stack.

Testing approach

Pattern: render → axe → focus first tab → ArrowRight → assert focus moved → Enter → assert aria-selected flipped.

Jest + Testing Library + jest-axe

AccessibleTabs.test.jsx
// Jest + @testing-library/react + jest-axe
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe, toHaveNoViolations } from "jest-axe";
import AccessibleTabs from "./AccessibleTabs";

expect.extend(toHaveNoViolations);

const tabs = [
  { id: "a", label: "A", content: "A content" },
  { id: "b", label: "B", content: "B content" },
];

test("exposes tablist with aria-label", () => {
  render(<AccessibleTabs tabs={tabs} label="Example" />);
  expect(screen.getByRole("tablist", { name: "Example" })).toBeInTheDocument();
});

test("arrow key moves focus; Enter activates (manual mode)", async () => {
  const user = userEvent.setup();
  render(<AccessibleTabs tabs={tabs} label="Example" />);
  const a = screen.getByRole("tab", { name: "A" });
  const b = screen.getByRole("tab", { name: "B" });
  a.focus();
  await user.keyboard("{ArrowRight}");
  expect(b).toHaveFocus();
  // Panel A still selected until Enter is pressed:
  expect(a).toHaveAttribute("aria-selected", "true");
  await user.keyboard("{Enter}");
  expect(b).toHaveAttribute("aria-selected", "true");
});

test("has no axe violations", async () => {
  const { container } = render(<AccessibleTabs tabs={tabs} label="Example" />);
  expect(await axe(container)).toHaveNoViolations();
});

Vitest + Testing Library

AccessibleTabs.test.jsx
// Vitest + @testing-library/react
import { expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import AccessibleTabs from "./AccessibleTabs";

test("renders one visible tabpanel at a time", () => {
  render(
    <AccessibleTabs
      tabs={[
        { id: "a", label: "A", content: "A" },
        { id: "b", label: "B", content: "B" },
      ]}
      defaultId="a"
    />
  );
  expect(screen.getByRole("tabpanel", { name: "A" })).toBeVisible();
});

Playwright + @axe-core/playwright

tabs.spec.js
// Playwright + @axe-core/playwright
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("tabs page has no serious a11y violations", async ({ page }) => {
  await page.goto("/en/accessible-tabs");
  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag22aa"])
    .analyze();
  const serious = results.violations.filter(
    (v) => v.impact === "critical" || v.impact === "serious"
  );
  expect(serious).toEqual([]);
});

test("ArrowRight focuses next tab; Enter activates it", async ({ page }) => {
  await page.goto("/en/accessible-tabs");
  const profile = page.getByRole("tab", { name: "Profile" });
  const billing = page.getByRole("tab", { name: "Billing" });
  await profile.focus();
  await page.keyboard.press("ArrowRight");
  await expect(billing).toBeFocused();
  await page.keyboard.press("Enter");
  await expect(billing).toHaveAttribute("aria-selected", "true");
});
8

Manual QA checklist

Tick each item before shipping.

Manual QA checklist

  • Tablist uses role="tablist" with aria-label
  • Each tab is a real <button> with role="tab"
  • Each tab has aria-controls pointing at its panel
  • Only ONE tab has tabindex="0" at a time (roving)
  • Each panel has role="tabpanel" + aria-labelledby
  • Arrow keys move focus (Left/Right or Up/Down)
  • Enter / Space activates the focused tab
  • Vertical tabs declare aria-orientation="vertical"
  • Selected-tab indicator uses MORE than just color
  • Zero critical / serious axe violations
9

Common mistakes

The tab bugs we see on nearly every audit.

Common mistakes

  • Divs with onClick. No role, no keyboard, no SR support.
  • tabindex="0" on every tab. Users Tab through all of them.
  • Vertical tabs with Left/Right arrows. Users expect Up/Down.
  • Auto-activation on expensive panels. Hammers your API as users arrow through.
  • Selected tab shown with color only. Fails for color-blind users.
  • Panel content not focusable. Add tabindex="0" to the panel.
10

Download & next

Grab the code bundle, or jump to the next guide.

Download and next

© 2026 Aria Probe. All rights reserved
Powered by techtipsall Logo