Tabs

A content-panel switcher: the active trigger paints with the highlight over a muted rail.

Basic

State lives in data-state="active|inactive" on triggers and panels. Add data-stisla-tabs to the root and the @stisla/vanilla layer wires the ARIA, roving tabindex, and arrow-key navigation from your data-values. Pair each .tabs__trigger with a .tabs__panel by data-value. The active trigger rises out of the rail as a pill; only the active panel shows. The demos below are live.

Keyboard

Tabs follow the WAI-ARIA tabs pattern with a roving tabindex. Only the active trigger is in the tab order; arrow keys move focus along the list.

  • Tab: focus the active trigger (or leave the list if a trigger is already focused)
  • ArrowRight / ArrowLeft: move focus through enabled triggers (wraps). ArrowDown / ArrowUp in vertical mode.
  • Home / End: focus the first / last enabled trigger
  • Enter / Space: activate the focused trigger (only needed in manual activation mode)

Automatic activation (the default) commits the focused trigger as soon as arrow keys move focus. Manual mode decouples them; see the Manual activation section below.

With icons

Drop an <i> next to the label. Icons scale with the trigger's font-size (1em).

Vertical

Add .tabs--vertical to flip the layout: the rail becomes a column on the inline-start side and panels fill the remaining row.

Disabled trigger

Add data-disabled (or a native disabled on a button) to fade a trigger and block it.

Manual activation

Set data-stisla-tabs-activation-mode="manual" to decouple focus from selection. Arrow keys move focus only; Space or Enter commits via native button click.

Programmatic control

Resolve an instance via Stisla.Tabs.getOrCreate(el) and drive it with setValue(value). The instance fires stisla:tabs:changing (cancelable) and stisla:tabs:changed on every flip.

External triggers

Any element on the page can drive a tabs instance declaratively. Carry aria-controls="<tabsRootId>" + data-stisla-tabs-value="<value>" on the trigger; the click delegate flips the matching panel without imperative JS. The tabs root needs an explicit id for the wire-up.

Useful for sidebar links, toolbar buttons, command-palette entries, even a .toggle-group member that doubles as a tab switcher. Native click semantics carry, so buttons fire on click + Enter + Space, and anchors fire on click + Enter.

The .tabs__list is optional. Drop it entirely and the tabs run on external triggers alone. The instance resolves each value against its panels and reflects the active state back onto the triggers (data-state="active" + aria-current="page"), so a .sidebar used as a section nav lights its own row with no extra script. The active panel still comes from opts.value, an existing active trigger/panel, or the first panel.

Without a list

Drop the .tabs__list entirely and the external triggers become the whole trigger set. The instance resolves each value against its panels and writes data-state="active" + aria-current="page" back onto the active trigger, so the triggers light themselves with no extra script. Here a .toggle-group of plain toggles (no toggle-group JS) is the nav; the same wiring lets a .sidebar drive a settings page. The first panel activates on init.

Events

Two events fire on the tabs root. Both carry value (the new selection) and previousValue (the prior selection) in detail.

stisla:tabs:changing fires before the flip and is cancelable. Call preventDefault() on the event to keep the current selection (useful for unsaved-changes guards).

stisla:tabs:changed fires after the new panel is active. Use it to sync URL hash state or persist the last-open tab.

Customization

These variables retune .tabs. Override on the root or any wrapper.

VariableUse
--tabs-gapSpace between the rail and the panel
--tabs-list-heightRail height (horizontal mode)
--tabs-list-bgRail background
--tabs-list-radiusRail corner radius; triggers derive a concentric inner radius
--tabs-list-padding-block / -padding-inlineRail interior padding
--tabs-list-gapGap between triggers
--tabs-trigger-padding-inlineTrigger horizontal padding
--tabs-trigger-font-size / -font-weightTrigger label size / weight
--tabs-trigger-color-hoverHover text color on inactive triggers
--tabs-trigger-bg-active / -color-activeActive trigger fill / text (highlight tier)
--tabs-trigger-border-color-activeActive trigger rim; defaults to the active bg
--tabs-ringFocus outline color
--tabs-transition-durationTrigger state change; zeroed under reduced-motion