Tabs
Point a tab at a panel with data-tab and you are done. Roles, aria-selected, a roving tabindex and the arrow keys are all wired on load. The segmented control at the bottom of this page looks similar and is a different component — it owns a value, not a panel.
The markup contract
A _78-tabs list of _78-tab buttons, each with
data-tab="<panel id>", and the panels anywhere in the document. That is the whole
contract — _78.tabs mounts on load and adds the rest, including
_78-tabpanel on each panel.
Try it: click a tab, then move with ← / →, Home and
End. Every change fires _78:tabchange, which bubbles.
Four workspaces are above 80% of their seat allowance.
Next invoice is issued on the first of the month.
18 configuration changes in the last 30 days.
Nothing archived.
<div class="site-demo-block">
<div class="_78-tabs">
<button class="_78-tab" data-tab="t-usage">Usage <span class="_78-badge _78-badge-accent">4</span></button>
<button class="_78-tab" data-tab="t-billing">Billing</button>
<button class="_78-tab" data-tab="t-audit">Audit log</button>
<button class="_78-tab" data-tab="t-archived" disabled>Archived</button>
</div>
<div id="t-usage"><p>Four workspaces are above 80% of their seat allowance.</p></div>
<div id="t-billing"><p>Next invoice is issued on the first of the month.</p></div>
<div id="t-audit"><p>18 configuration changes in the last 30 days.</p></div>
<div id="t-archived"><p>Nothing archived.</p></div>
</div>
A tab with disabled (or aria-disabled="true") is dimmed and skipped. The
panels themselves need no classes — _78-tabpanel and hidden are
applied for you.
Choosing the initial tab
By default the first tab wins. Mark a different one with _78-active — or with
aria-selected="true", which is equivalent — and it is selected on mount.
Not this one.
This panel opens first, because its tab has _78-active.
Nor this one.
<div class="site-demo-block">
<div class="_78-tabs">
<button class="_78-tab" data-tab="t2-overview">Overview</button>
<button class="_78-tab _78-active" data-tab="t2-members">Members</button>
<button class="_78-tab" data-tab="t2-settings">Settings</button>
</div>
<div id="t2-overview"><p>Not this one.</p></div>
<div id="t2-members"><p>This panel opens first, because its tab has <code>_78-active</code>.</p></div>
<div id="t2-settings"><p>Nor this one.</p></div>
</div>
Segmented variant
Add _78-tabs-segmented for the compact segmented-control look — a filled track
with the selected tab raised out of it. It suits short, mutually exclusive choices such as a time
range. Give it display:inline-flex if you do not want it to span the container.
Today's activity.
This week's activity.
This month's activity.
<div class="site-demo-block">
<div class="_78-tabs _78-tabs-segmented" style="display:inline-flex">
<button class="_78-tab" data-tab="t3-day">Day</button>
<button class="_78-tab" data-tab="t3-week">Week</button>
<button class="_78-tab" data-tab="t3-month">Month</button>
</div>
<div id="t3-day"><p class="_78-text-sm">Today's activity.</p></div>
<div id="t3-week"><p class="_78-text-sm">This week's activity.</p></div>
<div id="t3-month"><p class="_78-text-sm">This month's activity.</p></div>
</div>
_78-seg instead. It looks almost identical and is a different
component with different ARIA.
JavaScript API — _78.tabs
Tabs that exist at load need no JavaScript. For a tab list you build later, mount it yourself — mounting is idempotent, so calling it twice on the same list is safe and returns the same controller.
// Mount one list, or everything inside a container you just inserted.
var tabs = _78.tabs.mount(document.querySelector('._78-tabs'));
_78.tabs.mountAll(myNewlyAddedPanel); // scope is optional; defaults to document
// mount() returns the controller, which is also parked on the element
// as list._78tabs — handy when you did not keep the return value.
tabs.el; // the ._78-tabs element
tabs.tabs; // array of the ._78-tab buttons
tabs.select(1); // select by index
tabs.select(1, true); // …and move focus to it
Listen for changes on the list, or anywhere above it — the event bubbles:
document.addEventListener('_78:tabchange', function (e) {
// e.detail = { index, tab, panel, id }
if (e.detail.id === 'tab-reports') refreshReports();
});
| Member | What it is |
|---|---|
_78.tabs.mount(list) | Mount one _78-tabs element. Returns the controller, or null if it holds no tabs. |
_78.tabs.mountAll(scope) | Mount every unmounted tab list inside scope (default document). |
list._78tabs | The controller for an already-mounted list. |
controller.select(i, moveFocus) | Select by index. Pass true to also focus the tab. |
controller.tabs | The tab buttons, in order. |
_78:tabchange | Fired on the list and bubbles. detail = { index, tab, panel, id }. |
Segmented control — _78-seg
A joined run of buttons where exactly one is active. It looks like the segmented tabs above and does a different job: a segmented control owns a value, not a panel. Nothing is shown or hidden when you click one — it changes what a value is, and tells you so with an event. A range switch, a chart type, kilometers or miles.
Which of the three
| Component | Use it for | ARIA | Event |
|---|---|---|---|
_78-tabs _78-tabs-segmented |
Showing one panel of several, in a compact style. | tablist / tab |
_78:tabchange |
_78-seg |
Choosing one value of several. Nothing is revealed. | radiogroup / radio |
_78:segchange |
_78-btn-group |
Several independent actions, spaced rather than joined — every button its own. See Buttons. | — plain buttons | — your own click |
Markup and behavior
A _78-seg element around _78-seg-btn buttons. Each option's value is its
data-value, falling back to the button's own text. Mark the starting option with
aria-checked="true" (aria-pressed="true" or _78-active work
too); with none of them, the first option wins — and that initial state does
not fire an event.
Try it: click, then move with ← / →. The group is a single tab stop, the arrow keys step over the disabled option, and Home / End jump to the first and last enabled ones.
Change it — the event lands here.
<div class="_78-seg" aria-label="Range">
<button class="_78-seg-btn" data-value="1d">1D</button>
<button class="_78-seg-btn" data-value="1w" aria-checked="true">1W</button>
<button class="_78-seg-btn" data-value="1m">1M</button>
<button class="_78-seg-btn" data-value="1y">1Y</button>
<button class="_78-seg-btn" data-value="all" disabled>All</button>
</div>
A _78-seg-btn with no type is given type="button" on mount, so
dropping one inside a <form> cannot submit it by accident.
Variants
<div class="site-demo-block">
<div>
<div class="_78-eyebrow _78-mb-8">Default</div>
<div class="_78-seg" aria-label="Chart type">
<button class="_78-seg-btn">Line</button>
<button class="_78-seg-btn">Bars</button>
<button class="_78-seg-btn">Candles</button>
</div>
</div>
<div>
<div class="_78-eyebrow _78-mb-8">_78-seg-sm _78-seg-accent</div>
<div class="_78-seg _78-seg-sm _78-seg-accent" aria-label="Units">
<button class="_78-seg-btn" aria-checked="true">Miles</button>
<button class="_78-seg-btn">Kilometers</button>
</div>
</div>
<div>
<div class="_78-eyebrow _78-mb-8">_78-seg-full</div>
<div class="_78-seg _78-seg-full" aria-label="Billing period">
<button class="_78-seg-btn">Monthly</button>
<button class="_78-seg-btn" aria-checked="true">Yearly</button>
<button class="_78-seg-btn">Two years</button>
</div>
</div>
</div>
| Class / attribute | What it does |
|---|---|
_78-seg | The group: a --bg3 inset track with a --border edge and 3px of padding. Scrolls sideways rather than wrapping if the options overflow. |
_78-seg-btn | One option. The active one is raised onto a --bg2 surface with an accent label. |
_78-seg-sm | Smaller: --control-h-sm tall, --fs-sm type. |
_78-seg-full | Fills its container, every option an equal share. |
_78-seg-accent | The active option becomes an accent fill instead of a raised surface. |
data-value | The option's value. Falls back to the button's text. |
aria-checked="true" | Which option starts selected. aria-pressed="true" and _78-active are equivalent. |
disabled | Dims the option and takes it out of the arrow-key run. |
JavaScript API — _78.seg
_78.seg.mountAll() runs on load over every _78-seg in the page, so a group
written in your HTML needs no JavaScript at all. Mount the ones you inject later.
var seg = _78.seg.mount(document.querySelector('._78-seg'));
seg.value; // '1w' — data-value, falling back to the button's text
seg.index; // 1
seg.buttons; // the option elements, in order
seg.select(2); // select by index
seg.select(2, true); // …and move focus to it
_78.seg.mountAll(myNewPanel); // scope optional; defaults to document
document.addEventListener('_78:segchange', function (e) {
// e.detail = { index, button, value } — the event bubbles from the group
if (e.target.id === 'range') loadRange(e.detail.value);
});
| Member | What it is |
|---|---|
_78.seg.mount(el) | Mount one group. Returns the controller, or null if it holds no options. Idempotent. |
_78.seg.mountAll(scope) | Mount every unmounted group inside scope (default document). |
el._78seg | The controller for an already-mounted group. |
seg.value · seg.index | The current value and its position. |
seg.buttons | The option buttons, in order. |
seg.select(i, moveFocus) | Select by index. Pass true to also focus it. |
_78:segchange | Fired on the group and bubbles. detail = { index, button, value }. |
aria-label (or aria-labelledby) naming the setting —
“Range”, not “1D 1W 1M”. The options already announce themselves; what a
screen reader cannot infer is what they are options for.