Tabs
Switches between sibling views without leaving the page.
When to Use
Use when:
Two or more views sit at the same level and the reader looks at one at a time — content categories, settings sections, filter groups. Tabs preserve context that navigation would break.
Don't use when:
The reader needs to compare views side by side, or the content is sequential — steps need a stepper. For expanding several sections at once use Accordion.
Variants
Underline (default)
The standard page-level treatment. A count renders a badge after the label.
<?= ws_tabs([
['id' => 'overview', 'label' => 'Overview', 'active' => true],
['id' => 'details', 'label' => 'Details'],
['id' => 'resources', 'label' => 'Resources', 'count' => 5],
]) ?>Pill
Lighter weight, for filtering a list below rather than switching a whole view.
<?= ws_tabs($items, ['variant' => 'pill']) ?>
Segmented
A joined control for two to four mutually exclusive modes — time ranges, view density. Don't use it for more than four.
<?= ws_tabs($items, ['variant' => 'segmented']) ?>
Sizes
Small
Medium (default)
Large
States
| State | Behavior |
|---|---|
| Active | .is-active plus aria-selected="true". Exactly one tab should start active — the component doesn't enforce it. |
| Hover | Colour shift over --transition-fast. |
| Focus | Native button focus ring — tabs are real <button> elements. |
| Disabled | .is-disabled. Visual and click-blocking only — no aria-disabled or disabled attribute is emitted, so the tab stays in the tab order and isn't announced as unavailable. |
| Gated | .is-gated plus a lock icon, for premium features. Deliberately still focusable so the user can discover what's behind the gate. |
| Scrollable | On by default: .ws-tabs--scrollable lets the row scroll horizontally rather than wrap on narrow screens. |
Real-World Usage
A library listing filter: counts come from the query so the reader sees how much sits behind each tab, and the active tab is derived from the URL so the state survives a reload.
<?php $current = $_GET['type'] ?? 'all'; ?>
<?= ws_tabs([
['id' => 'all', 'label' => 'All', 'count' => $counts['all'],
'active' => $current === 'all'],
['id' => 'exercises', 'label' => 'Exercises', 'count' => $counts['exercises'],
'active' => $current === 'exercises'],
], ['id' => 'library-tabs', 'variant' => 'pill']) ?>Options
Tabs options
| Option | Type | Default | Purpose |
|---|---|---|---|
$items | array | [] | Positional array of tab definitions |
id | string | 'ws-tabs-' . uniqid() | Container ID. Set it explicitly if JS needs a handle. |
variant | string | 'underline' | underline | pill | segmented |
size | string | 'md' | sm | md | lg |
scrollable | bool | true | Horizontal scroll instead of wrapping |
class | string | '' | Additional CSS classes |
Item options
| Option | Type | Default | Purpose |
|---|---|---|---|
id | string | 'tab-' . $index | Emitted as data-tab, and used to build aria-controls as {id}-panel |
label | string | '' | Tab text |
icon | string | null | Leading Material icon |
count | int | null | Badge count after the label |
active | bool | false | Initially selected |
disabled | bool | false | Visually disabled and click-blocked |
gated | bool | false | Lock icon for premium gating |
info
The component renders the tab row only — it doesn't create panels. You supply elements with
id="{tabId}-panel" and show or hide them yourself; aria-controls already points there.
Accessibility
The markup borrows the WAI-ARIA Tabs pattern but the keyboard half of that pattern isn't implemented. Read the gap note before relying on it.
| Concern | Behavior |
|---|---|
| ARIA | The row is role="tablist"; each button is role="tab" with aria-selected and aria-controls pointing at {id}-panel. |
| Keyboard | Every tab is a real <button>, so Tab reaches each one and Enter/Space activates it. That comes from the element, not from any tab-specific code. |
| Focus | Native focus ring on each button. |
| Panels | Yours to wire. Give each panel role="tabpanel" and aria-labelledby pointing back at its tab, and hide inactive ones with the hidden attribute rather than display: none in a class. |
| Colour independence | The active tab is marked by colour and an underline or fill. Keep aria-selected accurate — that's what carries the state non-visually. |
warning
Known gaps — this page previously documented all of these as working:
(1) No arrow-key navigation.
(2) No roving
(3) No
(1) No arrow-key navigation.
wsTabsInit() binds click only; ←/→/Home/End do nothing. This matters more here than on Accordion, because role="tab" tells screen-reader users arrows will work.
(2) No roving
tabindex. Every tab is in the tab order, so Tab steps through all of them rather than jumping past the tablist as the pattern expects.
(3) No
aria-disabled. A disabled tab is styled and click-blocked but still focusable and announced as available.
Tokens
| Token | Used for |
|---|---|
--mainsite-primary / --mainsite-light | Active tab colour and its underline or fill |
--bg-surface | Segmented-variant active segment |
--gray-50 / --gray-100 / --gray-200 | Track, hover fill, and the underline rule |
--gray-400 / --gray-500 | Inactive label and the disabled state |
--text-dark / --text-inverse | Label colours |
--radius-full / --radius-lg | Pill and segmented corners |
--shadow-sm | Segmented active segment elevation |
--text-micro / --text-meta / --text-small / --text-ui / --text-h4 | Label and count type scale across sizes |
--font-body / --font-medium / --font-semibold | Face, and the weight bump on the active tab |
--transition-fast | Hover and active transitions |
CSS Classes
| Class | Purpose |
|---|---|
.ws-tabs | Outer container. Auto-initialised on DOMContentLoaded. |
.ws-tabs--pill / --segmented | Variants (underline is the default and emits nothing) |
.ws-tabs--sm / --lg | Size modifiers |
.ws-tabs--scrollable | Horizontal overflow scrolling |
.ws-tabs__nav | The role="tablist" row |
.ws-tabs__btn | Each tab button |
.ws-tabs__btn.is-active | Selected tab |
.ws-tabs__btn.is-disabled | Disabled tab |
.ws-tabs__btn.is-gated | Gated tab |
.ws-tabs__icon | Leading icon |
.ws-tabs__count | Badge count |
.ws-tabs__lock / .ws-tabs__lock-icon | Gated lock indicator |
.ws-tabs__label | Label wrapper — emitted but has no CSS rule. A hook, not an active style. |
Files
| File | Purpose |
|---|---|
includes/components/helpers.php | ws_tabs() helper function |
includes/components/tabs.php | Template — markup and ARIA wiring |
includes/components/components.css | Styles (.ws-tabs rules) |
includes/components/components.js | wsTabsInit() — click handling and the auto-init |