Tabs

A content-panel switcher. Active trigger paints with the highlight tint over a muted rail.

For a segmented control with no content panels, see Toggle group. The active trigger paints the same way in both (highlight bg matching the trigger's border); the difference is the rail and the panel switching. Toggle group is an outline-neutral cluster on a transparent rim; tabs sits on a --st-surface-2 bed and switches a matching .tabs__panel below the rail.

Anatomy

The component scans for triggers and panels inside the root and matches them by data-value. Author writes data-stisla-tabs and the value pairs; the class wires every ARIA attribute (role, aria-selected, aria-controls, aria-labelledby, roving tabindex) on init. IDs auto-generate when missing.

<div class="tabs" data-stisla-tabs>
  <div class="tabs__list">
    <button class="tabs__trigger" data-value="overview">Overview</button>
    <button class="tabs__trigger" data-value="activity">Activity</button>
  </div>
  <div class="tabs__panel" data-value="overview">…</div>
  <div class="tabs__panel" data-value="activity">…</div>
</div>

Basic

Three triggers, three panels. The first enabled trigger activates on init.

The overview pane gives you the big picture. At-a-glance metrics and recent changes.

Activity log lists every recent event in reverse chronological order.

Settings content lives here. Name, preferences, integrations.

<div class="tabs w-100" data-stisla-tabs>
  <div class="tabs__list">
    <button class="tabs__trigger" data-value="overview">Overview</button>
    <button class="tabs__trigger" data-value="activity">Activity</button>
    <button class="tabs__trigger" data-value="settings">Settings</button>
  </div>
  <div class="tabs__panel" data-value="overview">
    <p>The overview pane gives you the big picture. At-a-glance metrics and recent changes.</p>
  </div>
  <div class="tabs__panel" data-value="activity">
    <p>Activity log lists every recent event in reverse chronological order.</p>
  </div>
  <div class="tabs__panel" data-value="settings">
    <p>Settings content lives here. Name, preferences, integrations.</p>
  </div>
</div>

With icons

Drop an <i> next to the trigger label. Icons scale with the trigger's font-size (1em), so a custom size flows through.

3 unread messages.

1 draft saved.

Last sent 2 hours ago.

<div class="tabs w-100" data-stisla-tabs>
  <div class="tabs__list">
    <button class="tabs__trigger" data-value="inbox">
      <i data-lucide="inbox"></i>
      Inbox
    </button>
    <button class="tabs__trigger" data-value="drafts">
      <i data-lucide="file-text"></i>
      Drafts
    </button>
    <button class="tabs__trigger" data-value="sent">
      <i data-lucide="send"></i>
      Sent
    </button>
  </div>
  <div class="tabs__panel" data-value="inbox">
    <p>3 unread messages.</p>
  </div>
  <div class="tabs__panel" data-value="drafts">
    <p>1 draft saved.</p>
  </div>
  <div class="tabs__panel" data-value="sent">
    <p>Last sent 2 hours ago.</p>
  </div>
</div>

Vertical

Add .tabs--vertical to flip the layout. The rail becomes a column on the inline-start side; panels fill the remaining row. Arrow keys swap to / .

Name, avatar, and login details.

Invoices, payment methods, and subscription plan.

Members of your workspace.

Email and in-app notification preferences.

<div class="tabs tabs--vertical w-100" data-stisla-tabs>
  <div class="tabs__list">
    <button class="tabs__trigger" data-value="account">
      <i data-lucide="user"></i>
      Account
    </button>
    <button class="tabs__trigger" data-value="billing">
      <i data-lucide="credit-card"></i>
      Billing
    </button>
    <button class="tabs__trigger" data-value="team">
      <i data-lucide="users"></i>
      Team
    </button>
    <button class="tabs__trigger" data-value="notifications">
      <i data-lucide="bell"></i>
      Notifications
    </button>
  </div>
  <div class="tabs__panel" data-value="account">
    <p>Name, avatar, and login details.</p>
  </div>
  <div class="tabs__panel" data-value="billing">
    <p>Invoices, payment methods, and subscription plan.</p>
  </div>
  <div class="tabs__panel" data-value="team">
    <p>Members of your workspace.</p>
  </div>
  <div class="tabs__panel" data-value="notifications">
    <p>Email and in-app notification preferences.</p>
  </div>
</div>

Disabled trigger

Mark a trigger with data-disabled or the native disabled attribute. The class skips it for click, arrow navigation, and initial activation.

Home pane.

Archive pane (disabled trigger).

Trash pane.

<div class="tabs w-100" data-stisla-tabs>
  <div class="tabs__list">
    <button class="tabs__trigger" data-value="home">Home</button>
    <button class="tabs__trigger" data-value="archive" data-disabled>Archive</button>
    <button class="tabs__trigger" data-value="trash">Trash</button>
  </div>
  <div class="tabs__panel" data-value="home">
    <p>Home pane.</p>
  </div>
  <div class="tabs__panel" data-value="archive">
    <p>Archive pane (disabled trigger).</p>
  </div>
  <div class="tabs__panel" data-value="trash">
    <p>Trash pane.</p>
  </div>
</div>

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.

Pane one. Arrow keys move focus without changing the active panel.

Pane two.

Pane three.

<div class="tabs w-100" data-stisla-tabs data-stisla-tabs-activation-mode="manual">
  <div class="tabs__list">
    <button class="tabs__trigger" data-value="one">One</button>
    <button class="tabs__trigger" data-value="two">Two</button>
    <button class="tabs__trigger" data-value="three">Three</button>
  </div>
  <div class="tabs__panel" data-value="one">
    <p>Pane one. Arrow keys move focus without changing the active panel.</p>
  </div>
  <div class="tabs__panel" data-value="two">
    <p>Pane two.</p>
  </div>
  <div class="tabs__panel" data-value="three">
    <p>Pane three.</p>
  </div>
</div>

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.

Alpha pane.

Beta pane.

Gamma pane.

Listening for stisla:tabs:changed…
<div class="d-flex flex-column gap-3 w-100">
  <div id="tabsDemoProgrammatic" class="tabs" data-stisla-tabs>
    <div class="tabs__list">
      <button class="tabs__trigger" data-value="alpha">Alpha</button>
      <button class="tabs__trigger" data-value="beta">Beta</button>
      <button class="tabs__trigger" data-value="gamma">Gamma</button>
    </div>
    <div class="tabs__panel" data-value="alpha"><p>Alpha pane.</p></div>
    <div class="tabs__panel" data-value="beta"><p>Beta pane.</p></div>
    <div class="tabs__panel" data-value="gamma"><p>Gamma pane.</p></div>
  </div>
  <div class="d-flex gap-2 flex-wrap">
    <button type="button" class="btn btn--outline btn--neutral btn--sm" data-tabs-demo="alpha">Open Alpha</button>
    <button type="button" class="btn btn--outline btn--neutral btn--sm" data-tabs-demo="beta">Open Beta</button>
    <button type="button" class="btn btn--outline btn--neutral btn--sm" data-tabs-demo="gamma">Open Gamma</button>
  </div>
  <pre id="tabsDemoLog" class="kbd d-block" style="padding: 0.5rem 0.75rem; min-height: 2.25rem; white-space: pre-wrap;">Listening for stisla:tabs:changed…</pre>
</div>
<script>
  document.addEventListener('DOMContentLoaded', () => {
    const root = document.getElementById('tabsDemoProgrammatic');
    const log = document.getElementById('tabsDemoLog');
    if (!root || !log) return;

    const inst = Stisla.Tabs.getOrCreate(root);

    document.querySelectorAll('[data-tabs-demo]').forEach((btn) => {
      btn.addEventListener('click', () => {
        inst.setValue(btn.dataset.tabsDemo);
      });
    });

    root.addEventListener('stisla:tabs:changed', (e) => {
      log.textContent = `changed → ${e.detail.value} (from ${e.detail.previousValue})`;
    });
  });
</script>

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.

Overview pane. Open me from the external buttons above.

Activity pane.

Settings pane.

<div class="d-flex flex-column gap-4 w-100">
  <div class="d-flex gap-2 flex-wrap">
    <button type="button" class="btn btn--outline btn--neutral btn--sm" aria-controls="tabsDemoExternal" data-stisla-tabs-value="overview">Jump to Overview</button>
    <button type="button" class="btn btn--outline btn--neutral btn--sm" aria-controls="tabsDemoExternal" data-stisla-tabs-value="activity">Jump to Activity</button>
    <button type="button" class="btn btn--outline btn--neutral btn--sm" aria-controls="tabsDemoExternal" data-stisla-tabs-value="settings">Jump to Settings</button>
  </div>
  <div id="tabsDemoExternal" class="tabs" data-stisla-tabs>
    <div class="tabs__list">
      <button class="tabs__trigger" data-value="overview">Overview</button>
      <button class="tabs__trigger" data-value="activity">Activity</button>
      <button class="tabs__trigger" data-value="settings">Settings</button>
    </div>
    <div class="tabs__panel" data-value="overview"><p>Overview pane. Open me from the external buttons above.</p></div>
    <div class="tabs__panel" data-value="activity"><p>Activity pane.</p></div>
    <div class="tabs__panel" data-value="settings"><p>Settings pane.</p></div>
  </div>
</div>

Customization

Tabs ship 17 component variables across three groups. Concentric inner radius is derived from the outer radius minus the rail padding (V3.md §3.4).

List (the rail)

Variable Default Use
--tabs-list-radius var(--st-tabs-radius, var(--st-radius)) Outer rail radius. Trigger radius derives from this minus the rail padding for concentric corners.
--tabs-list-height 2.25rem Outer rail height (density multiplies once). Trigger height fills via flex stretch.
--tabs-list-padding 0.25rem Padding around triggers. Subtracts from the trigger's effective height and the concentric inner radius.
--tabs-list-gap 0.125rem Space between adjacent triggers.
--tabs-list-bg var(--st-surface-2) Rail fill. Surface-2 is the canonical muted bed that lets the active paper rise.
--tabs-list-color var(--st-muted-foreground) Default trigger text color. Triggers inherit; the active state overrides to --st-foreground.
--tabs-gap 0.75rem Space between the rail and the panel below it (or beside it in vertical mode).

Trigger

Variable Default Use
--tabs-trigger-padding-x calc(0.75rem * var(--st-density)) Horizontal padding inside each trigger.
--tabs-trigger-gap 0.5rem Space between an icon and its label inside the trigger.
--tabs-trigger-font-size 0.875rem Trigger label size. Icons scale with this via 1em sizing.
--tabs-trigger-font-weight 500 Trigger label weight.
--tabs-trigger-hover-color var(--st-foreground) Color applied on hover for non-active triggers (no bg change, so the active paper stays distinct).
--tabs-trigger-active-bg var(--st-highlight) Background of the active trigger. Highlight tint matches .toggle's active member paint, so a tab strip baselines with a toggle-group cluster on the same surface.
--tabs-trigger-active-color var(--st-highlight-foreground) Active trigger text color, paired with the highlight bg.
--tabs-trigger-active-border-color var(--tabs-trigger-active-bg) Active border color. Defaults to match the bg for a clean solid chip with no visible rim. Rest triggers carry a transparent 1px border so the active border doesn't reflow the layout. Override to add a distinct ring.

Motion and focus

Variable Default Use
--tabs-transition-duration 0.15s Trigger color and bg transition speed. Zeroed under prefers-reduced-motion: reduce.
--tabs-ring var(--st-ring) Focus outline color on triggers and panels.