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 JS component (<af-tabs>, a kit wrapper, or your own). 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, tabindex="0" (labels are not keyboard-focusable without it)
  • 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

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. <af-tabs> does this for you.

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