Tabs
Summary
Tabs switch between peer views of the same thing
in one space: underline style, horizontal or vertical, with full
arrow-key navigation and ARIA wiring applied by the component
(nothing to author beyond classes and hrefs). Auto-init binds
every .soma-tabs; add
.soma-tabs-disabled to opt a block out. Stacked
disclosures that collapse independently are the
Expander (accordion).
When to use
| Component | Use it for |
|---|---|
| Tabs | 3–7 peer views of one entity (Overview / Activity / Settings). One visible at a time. |
Tabs -vertical | Longer tab lists beside tall content: settings categories. |
| Accordion / expander | Independent sections the user opens as needed; several can be open at once. |
Examples
Keyboard navigation
| Key | Action |
|---|---|
| → / ↓ | Activate and focus the next tab, wrapping past the end. Horizontal arrows follow visual direction (ARIA APG). Under RTL the tab row runs right-to-left, so ← advances instead. |
| ← / ↑ | Activate and focus the previous tab, wrapping past the start (mirrored under RTL). |
| Home / End | First / last tab. |
| Tab | Leave the tablist. Roving tabindex: only the active tab is tabbable (tabindex="0"), the rest are -1. |
Selection follows focus — arrowing to a tab activates its pane immediately; there is no separate "focused but not selected" state. ↑/↓ behave the same in both orientations, so vertical tabs need no extra configuration.
ARIA wiring
On bind, the component applies the full tabs pattern: the menu
becomes role="tablist"; each anchor
role="tab" with aria-selected, a
generated id when it has none, and aria-controls
pointing at its pane; the <li> wrappers get
role="presentation" (a tablist's owned children
must be the tabs themselves); each pane becomes
role="tabpanel" with aria-labelledby
back to its tab and aria-hidden tracking
visibility. The state attributes — aria-selected,
roving tabindex, aria-hidden — are
re-asserted on every activation, so the markup stays correct as
tabs switch.
HTML
The core contract — anchors point at pane ids via
href="#paneId"; mark the initially active item and
pane with .soma-active:
<div class="soma-tabs">
<ul class="soma-tabs-menu">
<li class="soma-tabs-menu-item soma-active"><a href="#one">One</a></li>
<li class="soma-tabs-menu-item"><a href="#two">Two</a></li>
</ul>
<div class="soma-tabs-pane soma-active" id="one">…</div>
<div class="soma-tabs-pane" id="two">…</div>
</div>
In the vertical form the menu sits beside the panes; same markup, one extra class:
<div class="soma-tabs soma-tabs-vertical">
<ul class="soma-tabs-menu">…</ul>
<div class="soma-tabs-pane soma-active" id="general">…</div>
</div>
Opt out of auto-init when another script owns the switching — Soma leaves the block completely alone (no roles, no keys):
<div class="soma-tabs soma-tabs-disabled">…</div>
CSS classes
| Class | Effect |
|---|---|
.soma-tabs | Root. -vertical lays the menu beside the panes; -disabled opts server-rendered markup out of auto-init. |
.soma-tabs-menu / -menu-item | The tab list; the active item carries .soma-active (underline + primary color). |
.soma-tabs-pane | A pane; visible when .soma-active. |
JavaScript
Constructor
| Member | Description |
|---|---|
Soma.tabs(elOrSelector) | Get or create the singleton for a .soma-tabs root (throws when nothing matches or the class is missing). Auto-init already binds every root without -disabled, so call this to reach the API, or to bind a root rendered after load. |
Instance methods
| Member | Description |
|---|---|
.activate(anchor) | Switch to the tab whose anchor element is passed: toggles .soma-active, aria-selected, roving tabindex and pane visibility, then dispatches change. A no-op when the anchor's pane doesn't resolve. Does not move focus (keyboard handling focuses separately). |
.activateById(paneId) | Same, addressed by the pane's id (the handy form for deep links). |
.on(event, fn) / .off(event, fn) | Subscribe / unsubscribe: 'change'. Listeners attach to the root element. |
.destroy() | Unbind and strip the ARIA the component generated (roles, aria-selected/aria-controls, roving tabindex, minted tab ids, the panes' role/aria-labelledby/aria-hidden) and release the singleton. Author-supplied ids and the .soma-active state classes stay. |
All methods return the instance, so calls chain. The underlying
DOM event is a bubbling soma-tabs-change
CustomEvent dispatched on the root with
e.detail = { tab, pane } (the anchor and pane
elements), usable directly with addEventListener
for delegated listening.
Listen for changes to lazy-load a pane's content on first visit:
Soma.tabs('#build-tabs').on('change', (e) => {
if (e.detail.pane.id === 'atab-logs' && !e.detail.pane.dataset.loaded) {
e.detail.pane.dataset.loaded = 'true';
loadLogsInto(e.detail.pane);
}
});
Deep-link: open the tab named in the URL hash, and keep the hash in sync:
const tabs = Soma.tabs('#build-tabs');
if (location.hash) tabs.activateById(location.hash.slice(1));
tabs.on('change', (e) => {
history.replaceState(null, '', `#${e.detail.pane.id}`);
});
Bind a root rendered after page load (auto-init runs once, at
DOMContentLoaded). The call is a get-or-create
singleton, safe after any DOM update:
container.insertAdjacentHTML('beforeend', renderedTabsBlock);
Soma.tabs(container.querySelector('.soma-tabs'));
The live examples above, exactly as this page wires them:
const tabs = Soma.tabs('#tabs-api');
document.getElementById('tabs-goto-logs')
.addEventListener('click', () => tabs.activateById('atab-logs'));
tabs.on('change', (e) => {
document.getElementById('tabs-change-out').textContent =
`soma-tabs-change — ${e.detail.tab.textContent} (#${e.detail.pane.id})`;
});