// pattern

Accessible Tabs Pattern

A tabbed interface with proper role="tablist", arrow key navigation, roving tabindex, and automatic panel association. This is one of the few patterns that genuinely requires ARIA — there is no native HTML tab element.

aria-required wcag-2.2-aa

// 01 · live demo

Live Demo

Use Arrow Left/Arrow Right to switch tabs. Press Tab to move into the active panel.

Perceivable

Information and user interface components must be presentable to users in ways they can perceive. This means content can't be invisible to all of a user's senses.

  • Provide text alternatives for non-text content
  • Provide captions and alternatives for multimedia
  • Create content that can be presented in different ways without losing meaning
  • Make it easier for users to see and hear content

Inaccessible version (div-based):

Perceivable Operable Understandable

This tab interface uses plain <span> elements. No keyboard navigation, no ARIA roles, no screen reader announcements. Click works, but nothing else does.

No way to reach this panel with a keyboard. No role, no state, no relationship to the tab.

Screen readers see no tab structure here — just text and more text.

// 02 · the code

The Code

HTML
<!-- Tab container -->
<div class="tabs">

  <!-- Tab list: role="tablist" groups the tabs -->
  <div role="tablist" aria-label="Web accessibility topics">
    <button role="tab"
            aria-selected="true"
            aria-controls="panel-1"
            id="tab-1"
            tabindex="0">
      First Tab
    </button>
    <button role="tab"
            aria-selected="false"
            aria-controls="panel-2"
            id="tab-2"
            tabindex="-1">
      Second Tab
    </button>
    <button role="tab"
            aria-selected="false"
            aria-controls="panel-3"
            id="tab-3"
            tabindex="-1">
      Third Tab
    </button>
  </div>

  <!-- Panels: each linked to its tab via
       aria-labelledby and the tab's aria-controls -->
  <div role="tabpanel"
       id="panel-1"
       aria-labelledby="tab-1"
       tabindex="0">
    <h3>First Panel</h3>
    <p>Content for the first tab.</p>
  </div>

  <div role="tabpanel"
       id="panel-2"
       aria-labelledby="tab-2"
       tabindex="0"
       hidden>
    <h3>Second Panel</h3>
    <p>Content for the second tab.</p>
  </div>

  <div role="tabpanel"
       id="panel-3"
       aria-labelledby="tab-3"
       tabindex="0"
       hidden>
    <h3>Third Panel</h3>
    <p>Content for the third tab.</p>
  </div>

</div>
CSS
[role="tablist"] {
  display: flex;
  border-bottom: 2px solid #d1d5db;
  gap: 0.25rem;
}

[role="tab"] {
  padding: 0.5rem 1rem;
  border: none;
  border-bottom: 3px solid transparent;
  background: none;
  color: #6b7280;
  font-size: 0.875rem;
  font-weight: 600;
  cursor: pointer;
  margin-bottom: -2px;
  /* SC 2.5.8: minimum 24x24 target */
  min-height: 2.75rem;
}

[role="tab"]:hover {
  color: #1f2937;
}

[role="tab"][aria-selected="true"] {
  color: var(--color-primary);
  border-bottom-color: var(--color-primary);
}

/* SC 2.4.13: focus indicator with 3:1 contrast */
[role="tab"]:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: -2px;
  border-radius: 0.25rem;
}

[role="tabpanel"] {
  padding: 1.5rem;
}

[role="tabpanel"]:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
  border-radius: 0.25rem;
}

[role="tabpanel"][hidden] {
  display: none;
}

/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
  [role="tab"] {
    transition: none;
  }
}
JavaScript
function initTabs(tabContainer) {
  const tablist = tabContainer.querySelector('[role="tablist"]');
  const tabs = tablist.querySelectorAll('[role="tab"]');
  const panels = tabContainer.querySelectorAll('[role="tabpanel"]');

  function switchTab(newTab) {
    // Deactivate all tabs
    tabs.forEach(tab => {
      tab.setAttribute('aria-selected', 'false');
      tab.setAttribute('tabindex', '-1');
    });

    // Activate the new tab
    newTab.setAttribute('aria-selected', 'true');
    newTab.setAttribute('tabindex', '0');
    newTab.focus();

    // Show the correct panel, hide the rest
    panels.forEach(panel => {
      panel.hidden = true;
    });
    const newPanel = tabContainer.querySelector(
      '#' + newTab.getAttribute('aria-controls')
    );
    newPanel.hidden = false;
  }

  tablist.addEventListener('keydown', (e) => {
    const currentIndex = Array.from(tabs).indexOf(
      document.activeElement
    );
    let newIndex;

    switch (e.key) {
      case 'ArrowRight':
        // Next tab, wraps to first
        newIndex = (currentIndex + 1) % tabs.length;
        break;
      case 'ArrowLeft':
        // Previous tab, wraps to last
        newIndex = (currentIndex - 1 + tabs.length)
          % tabs.length;
        break;
      case 'Home':
        newIndex = 0;
        break;
      case 'End':
        newIndex = tabs.length - 1;
        break;
      default:
        return; // Don't prevent other keys
    }

    e.preventDefault();
    switchTab(tabs[newIndex]);
  });

  // Click handler for each tab
  tabs.forEach(tab => {
    tab.addEventListener('click', () => switchTab(tab));
  });
}

// Initialize all tab containers on the page
document.querySelectorAll('.tabs').forEach(initTabs);
Why buttons for tab triggers? Even though ARIA provides the tab semantics, native <button> elements give you keyboard activation (Enter/Space) and focusability for free. Using <div> or <span> would require adding tabindex and keydown listeners manually — unnecessary work when buttons handle it natively.

// 03 · why these decisions

Why These Decisions

Why ARIA is genuinely required here

Most patterns on this site use native HTML elements that provide built-in accessibility. Tabs are different. HTML has no <tabs>, <tab>, or <tabpanel> elements. Without ARIA roles, a screen reader sees a row of buttons and some content divs — it has no way to know these form a tabbed interface, which tab is selected, or which panel belongs to which tab. The role="tablist", role="tab", and role="tabpanel" roles are the only way to communicate this structure to assistive technology.

Why roving tabindex instead of tabindex="0" on all tabs?

Roving tabindex means only the active tab has tabindex="0", while all other tabs have tabindex="-1". This creates the expected keyboard interaction: pressing Tab moves focus into the tablist (landing on the active tab), then pressing Tab again moves focus into the panel content. If all tabs had tabindex="0", the user would have to tab through every single tab before reaching the panel — a poor experience with many tabs.

Why arrow keys for tab navigation?

Arrow keys are the expected convention for moving between tabs, as defined by the WAI-ARIA Authoring Practices. This matches how tabs work in desktop operating systems and other ARIA widget patterns (like toolbars and menubars). Screen reader users know to use arrow keys within composite widgets. Using Tab to move between tabs would break the expected pattern where Tab moves focus to the next component, not within the current one.

Why wrap arrow navigation at the ends?

When the user presses Arrow Right on the last tab, focus wraps to the first tab (and vice versa). This is a standard convention for tab widgets and prevents the user from getting "stuck" at either end. It also makes navigation faster since users can always reach any tab in at most half the total number of arrow presses.

Why aria-controls and aria-labelledby?

These two attributes create a bidirectional link between tabs and panels. aria-controls on a tab tells assistive technology which panel it controls. aria-labelledby on a panel tells assistive technology which tab labels it. Some screen readers use aria-controls to let users jump directly from a tab to its panel (e.g., JAWS with Ctrl+Alt+M). aria-labelledby ensures the panel announces its associated tab name when focused.

Why tabindex="0" on panels?

Tab panels need to be focusable so keyboard users can tab into the panel content after selecting a tab. Without tabindex="0", pressing Tab from the tablist would skip over the panel entirely and jump to whatever comes after the tabs component. The focus indicator on the panel also gives sighted keyboard users a clear visual cue of where they are.

// 04 · keyboard interaction

Keyboard Interaction

Key Action
Tab Moves focus into the tablist (on the active tab), then into the active panel, then out of the tabs component
Shift + Tab Moves focus backward through the same sequence: panel, active tab, previous component
Arrow Right Moves focus to the next tab and activates it. Wraps from last tab to first.
Arrow Left Moves focus to the previous tab and activates it. Wraps from first tab to last.
Home Moves focus to the first tab and activates it
End Moves focus to the last tab and activates it
Enter / Space Activates the focused tab (handled by native <button> — no extra JS needed)
Automatic vs. manual activation This implementation uses automatic activation: arrow keys both move focus and activate the tab immediately. The alternative is manual activation, where arrow keys only move focus and the user presses Enter/Space to activate. Automatic activation is recommended when panel content loads instantly. Use manual activation only if switching tabs triggers expensive operations like network requests.

// 05 · wcag 2.2 success criteria

WCAG 2.2 Success Criteria

This pattern satisfies the following WCAG 2.2 success criteria:

  • 2.1.1 Keyboard Level A — All tab functionality is operable via keyboard. Arrow keys navigate between tabs, Tab moves into panels, and Home/End jump to first/last tabs.
  • 2.1.2 No Keyboard Trap Level A — Users can always tab out of the tab component. Focus moves naturally from tablist to panel to the next page element.
  • 1.3.1 Info and Relationships Level A — ARIA roles (tablist, tab, tabpanel) convey the tabbed interface structure. aria-controls and aria-labelledby establish relationships between tabs and their panels.
  • 2.4.3 Focus Order Level A — Focus follows a logical sequence: active tab, then panel content. Roving tabindex ensures only the active tab is in the tab order.
  • 2.4.11 Focus Not Obscured (Minimum) Level AA — Focused tabs and panels are fully visible, not hidden behind other content.
  • 2.4.13 Focus Appearance Level AAA — Both tabs and panels have visible focus indicators using a 2px solid outline with 3:1 contrast ratio.
  • 2.5.8 Target Size (Minimum) Level AA — Tab triggers meet the 24x24px minimum target size via min-height: 2.75rem.
  • 4.1.2 Name, Role, Value Level A — Each tab exposes its name (button text), role (tab), and selected state (aria-selected) to the accessibility tree.
  • 3.2.3 Consistent Navigation Level AA — Tab navigation follows the standard WAI-ARIA pattern: arrow keys within the tablist, Tab to move into/out of the component.

// 06 · screen reader behavior

Screen Reader Behavior

When navigating to the tablist

  • NVDA: "Web accessibility topics, tab list" — announces the tablist label, then "Perceivable, tab, selected, 1 of 4" — announces the tab name, role, state, and position
  • JAWS: "Perceivable tab, 1 of 4" — announces tab name, role, and position
  • VoiceOver: "Perceivable, selected, tab, 1 of 4, Web accessibility topics, tab group" — announces name, state, role, position, and group label

When arrowing to another tab

  • NVDA: "Operable, tab, selected, 2 of 4" — announces the new tab name, role, state, and position
  • JAWS: "Operable tab, 2 of 4" — announces tab name and position
  • VoiceOver: "Operable, selected, tab, 2 of 4" — confirms the new selection

When tabbing into the panel

  • NVDA: "Operable, tab panel" — announces the panel label (from aria-labelledby) and role
  • JAWS: "Operable, tab panel" — same identification
  • VoiceOver: "Operable, tab panel" — panel label and role, then content becomes navigable

After entering the panel, all screen readers allow normal content navigation using arrow keys, headings, links, and other standard navigation methods.

Position announcements

Screen readers announce "1 of 4", "2 of 4", etc. because the role="tab" elements are direct children of the role="tablist" container. This positional information helps users understand how many tabs exist and where they are in the set — even without seeing the visual layout.

// 07 · common mistakes

Common Mistakes

Missing role="tablist", role="tab", or role="tabpanel" All three roles are required. Without role="tablist", screen readers don't know these elements form a tab interface. Without role="tab", individual tabs are just buttons. Without role="tabpanel", the content area has no semantic connection to the tablist. Omitting any one of these breaks the entire pattern for assistive technology users.
Not managing aria-selected Only the active tab should have aria-selected="true". All other tabs must have aria-selected="false". If you forget to update this when switching tabs, screen readers will announce the wrong tab as selected — or worse, multiple tabs as selected simultaneously. Always update aria-selected on every tab whenever the selection changes.
Missing aria-controls and aria-labelledby connections Each tab needs aria-controls pointing to its panel's id, and each panel needs aria-labelledby pointing back to its tab's id. These create the bidirectional relationship that assistive technology uses to connect tabs with their content. Without them, tabs and panels are semantically disconnected.
Not implementing arrow key navigation Tabs require arrow key navigation between tab triggers — this is the standard interaction pattern defined by WAI-ARIA. If you only support Tab key navigation, keyboard users must tab through every tab to reach the panel, and screen reader users won't find the expected arrow key behavior that all other tab implementations provide.
Using links instead of buttons for tab triggers Tab triggers should be <button> elements, not <a> links. Links imply navigation to a new page or location. Tabs switch visible content within the same page. Using links confuses screen reader users who expect link behavior (navigation) but get tab behavior (content switching). Links also have different default keyboard behavior that conflicts with the tab pattern.
Using display: none without the hidden attribute While CSS display: none hides panels visually, the hidden attribute provides a semantic signal that the element is not relevant. Use both: hidden as the HTML attribute and [role="tabpanel"][hidden] { display: none; } as a CSS safety net. This ensures panels are hidden even if CSS fails to load.

// 08 · native html vs. aria

Native HTML vs. ARIA

Unlike other patterns on this site, tabs have no native HTML equivalent. This table shows what native HTML still helps with and what ARIA must handle entirely.

Feature Native HTML contribution ARIA requirement
Tab trigger element <button> — focusable, keyboard-activatable role="tab" — overrides button semantics with tab semantics
Tab container <div> — structural container role="tablist" — identifies the group as a tab interface
Panel container <div> — structural container role="tabpanel" — identifies the content area
Selected state None aria-selected="true/false" — required on every tab
Tab-to-panel relationship None aria-controls on tab, aria-labelledby on panel
Tablist label None aria-label on the tablist describes the tab group's purpose
Arrow key navigation None JavaScript keydown handler — required by the pattern
Roving tabindex None JavaScript manages tabindex="0" / tabindex="-1"
Hiding inactive panels hidden attribute — semantic "not relevant" None — hidden handles this natively
The verdict Tabs are the poster child for when ARIA is necessary. There is no native HTML tab element, so you must use ARIA roles, states, and properties to communicate the tab structure. But native HTML still helps: <button> provides keyboard activation and focusability, and the hidden attribute handles panel visibility. The principle holds — use native HTML where you can, ARIA where you must.