Skip to content

Tabs

HTML-only — no JavaScript. Each trigger is a <label role="tab"> wrapping a presentational radio. Clicking the tab checks the radio; CSS shows the matching panel (up to 8).

The same .af-tabs classes work in a kit wrapper or your own JS. Drive selection in JS if you want; you must update aria-selected yourself. CSS cannot change attributes.

For settings / account section nav, use Nav Pills — not tabs.

Same name on every radio. Check the default. Put .af-tabs-list first, then panels in the same order.

  • Tab: id, aria-controls the panel, aria-selected,
  • Panel: id, role="tabpanel", aria-labelledby the tab, tabindex="0"
  • Radio: role="none" + aria-hidden="true" + tabindex="-1" (not in the accessibility tree)

Use a <label>, not a <button>: only a label can check the radio without JavaScript.

Overview content
Activity content
Settings content
<div class="af-tabs">
  <ul class="af-tabs-list" role="tablist" aria-label="Account">
    <li>
      <label class="af-tabs-trigger" role="tab" id="tab-overview" aria-controls="panel-overview">
        <input class="af-tabs-input" type="radio" name="account-tabs" checked tabindex="-1" aria-hidden="true" role="none" />
        Overview
      </label>
    </li>
    <li>
      <label class="af-tabs-trigger" role="tab" id="tab-activity" aria-controls="panel-activity">
        <input class="af-tabs-input" type="radio" name="account-tabs" tabindex="-1" aria-hidden="true" role="none" />
        Activity
      </label>
    </li>
    <li>
      <label class="af-tabs-trigger" role="tab" id="tab-settings" aria-controls="panel-settings">
        <input class="af-tabs-input" type="radio" name="account-tabs" tabindex="-1" aria-hidden="true" role="none" />
        Settings
      </label>
    </li>
  </ul>
  <div class="af-tabs-panel" role="tabpanel" id="panel-overview" aria-labelledby="tab-overview" tabindex="0">Overview content</div>
  <div class="af-tabs-panel" role="tabpanel" id="panel-activity" aria-labelledby="tab-activity" tabindex="0">Activity content</div>
  <div class="af-tabs-panel" role="tabpanel" id="panel-settings" aria-labelledby="tab-settings" tabindex="0">Settings content</div>
</div>

name must be unique per tabset on the page. Tab and panel ids must match across aria-controls / aria-labelledby.

Same structure and classes. CSS still styles [aria-selected="true"], but it will not move that attribute when the user changes tab.

On every selection change you must set aria-selected="true" on the active tab and aria-selected="false" on the rest. Toggle hidden on panels.

function selectTab(tabs, selected) {
  tabs.forEach((tab) => {
    const on = tab === selected;
    tab.setAttribute('aria-selected', on ? 'true' : 'false');
    document.getElementById(tab.getAttribute('aria-controls')).hidden = !on;
  });
}

In JS you can use <button role="tab"> instead of a label + radio — buttons are already tab stops, so you don’t need tabindex="0" or the radio hack.

NeedUse
Settings / account section linksaf-nav-pills
Hierarchical sidebaraf-side-nav

HTML-only:

  • role="tablist" + aria-label on .af-tabs-list
  • Each trigger: <label role="tab"> with id, aria-controls, aria-selected, and tabindex="0" (labels are skipped by Tab without it)
  • Radio: presentational (role="none", aria-hidden="true", tabindex="-1"), wrapped by the label
  • Panel: role="tabpanel", id, aria-labelledby the tab id, tabindex="0"
  • Inactive panels: display: none via CSS (removed from the accessibility tree)

JS:

  • Must update aria-selected on every change (true on the selected tab, false on the others) — not aria-current
  • Toggle hidden on inactive panels; keep the visible panel focusable
  • Keyboard (app JS): Left/Right, Home/End, Enter/Space