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.
HTML-only markup
Section titled “HTML-only markup”Same name on every radio. Check the default. Put .af-tabs-list first, then panels in the same order.
- Tab:
id,aria-controlsthe panel,aria-selected,tabindex="0"(labels are not keyboard-focusable without it) - Panel:
id,role="tabpanel",aria-labelledbythe 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.
<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" aria-selected="true" tabindex="0">
<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" aria-selected="false" tabindex="0">
<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" aria-selected="false" tabindex="0">
<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.
JavaScript
Section titled “JavaScript”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.
When not to use
Section titled “When not to use”| Need | Use |
|---|---|
| Settings / account section links | af-nav-pills |
| Hierarchical sidebar | af-side-nav |
Accessibility
Section titled “Accessibility”HTML-only:
role="tablist"+aria-labelon.af-tabs-list- Each trigger:
<label role="tab">withid,aria-controls,aria-selected, andtabindex="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-labelledbythe tabid,tabindex="0" - Inactive panels:
display: nonevia CSS (removed from the accessibility tree)
JS:
- Must update
aria-selectedon every change (trueon the selected tab,falseon the others) — notaria-current - Toggle
hiddenon inactive panels; keep the visible panel focusable - Keyboard (app JS): Left/Right, Home/End, Enter/Space