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. |