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

ComponentUse it for
Tabs3–7 peer views of one entity (Overview / Activity / Settings). One visible at a time.
Tabs -verticalLonger tab lists beside tall content: settings categories.
Accordion / expanderIndependent sections the user opens as needed; several can be open at once.

Examples

Horizontal

Overview pane. Arrow keys work too.

Activity pane.

Settings pane.

Vertical

soma-tabs-vertical:

General settings.

Security settings.

Advanced settings.

Programmatic + events

.activateById() and the change event:

Build pane.

Logs pane.

Artifacts pane.

Last change: (none)

Opted out

soma-tabs-disabled, inert markup:

Auto-init skipped this block: no ARIA roles, no key handling — the links are plain anchors.

Never shown by Soma.

For server-rendered markup whose switching another script owns.

Keyboard navigation

KeyAction
/ 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 / EndFirst / last tab.
TabLeave 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

ClassEffect
.soma-tabsRoot. -vertical lays the menu beside the panes; -disabled opts server-rendered markup out of auto-init.
.soma-tabs-menu / -menu-itemThe tab list; the active item carries .soma-active (underline + primary color).
.soma-tabs-paneA pane; visible when .soma-active.

JavaScript

Constructor

MemberDescription
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

MemberDescription
.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})`;
});