Tabs
Switches between sibling views without leaving the page.
When to Use
Variants
Underline (default)
The standard page-level treatment, shown with its panels. A count renders a badge after the label. Tab into the row, then use the arrow keys, Home and End.
A two-hour retrospective for a team of eight, run in person.
Four activities and one break, with the energy peak in the second hour.
Five templates and handouts, ready to print.
<?= ws_tabs([
['id' => 'overview', 'label' => 'Overview', 'active' => true],
['id' => 'details', 'label' => 'Details'],
['id' => 'resources', 'label' => 'Resources', 'count' => 5],
], ['label' => 'Workshop sections']) ?>
<?php ws_tab_panel_start('overview') ?> … <?php ws_tab_panel_end() ?>
<?php ws_tab_panel_start('details') ?> … <?php ws_tab_panel_end() ?>
<?php ws_tab_panel_start('resources') ?> … <?php ws_tab_panel_end() ?>Keeping the open tab
With param, the URL says which tab is open: ?tab=notes opens Notes, switching tabs updates it, and a GET form inside a panel sends it, so saving a note comes back to Notes rather than the first tab.
- Welcome 10 min
- Dot voting 30 min
<?= ws_tabs($items, ['label' => 'Workshop sections', 'param' => 'tab']) ?>
<?php ws_tab_panel_start('notes') ?>
<?php ws_stack_start(['tag' => 'form', 'attrs' => ['method' => 'get']]) ?>
... <!-- the panel adds <input type="hidden" name="tab" value="notes"> -->
<?php ws_stack_end() ?>
<?php ws_tab_panel_end() ?>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, aria-selected="true" and tabindex="0". Exactly one tab should start active; the component doesn't enforce it. With none active, the first enabled tab takes the Tab stop. |
| Hover | Colour shift over --transition-interactive. |
| Focus | The platform focus ring (--focus-ring) on the focused tab. Arrow keys move focus and selection together, so the ring and the active style land on the same tab. |
| Disabled | .is-disabled plus the disabled attribute: out of the Tab order, skipped by the arrow keys, and 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 |
label | string | '' | The tablist's aria-label. Set it when no visible heading names the row. |
class | string | '' | Additional CSS classes |
Item options
| Option | Type | Default | Purpose |
|---|---|---|---|
id | string | 'tab-' . $index | Emitted as data-tab, and used to build the tab's own id {id}-tab and its aria-controls {id}-panel. Keep it unique on the page. |
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 | A disabled button: dimmed, click-blocked, and skipped by Tab and the arrow keys |
gated | bool | false | Lock icon for premium gating |
ws_tabs renders the row; ws_tab_panel_start('{tabId}') and ws_tab_panel_end() write each panel after it. The panel gets id="{tabId}-panel", which the tab's aria-controls points at, the ws-tab-pane class, role="tabpanel" and its name, and it is open exactly when its tab is the active item, so a page that posts back to the Notes tab opens on Notes. components.js then shows the active panel and hides the rest of this row's, leaving any other tab row's alone.
Accessibility
The WAI-ARIA Tabs pattern with automatic activation: moving to a tab shows its panel.
| Concern | Behavior |
|---|---|
| ARIA | The row is role="tablist", named by the label option. Each tab is a real button with role="tab", id="{id}-tab", aria-selected, and aria-controls pointing at {id}-panel. |
| Keyboard | Roving tabindex: only the active tab is in the Tab order, so Tab enters the row on it and the next Tab leaves for the panel. →/← move to the next or previous tab and wrap at the ends; Home/End go to the first or last. Each move also activates the tab, and disabled tabs are skipped. Wired by wsTabsInit() in components.js. |
| Focus | The platform focus ring marks the focused tab; wsTabsSwitch() keeps tabindex in step when a page switches tabs from its own code. |
| Panels | ws_tab_panel_start writes role="tabpanel" and aria-labelledby="{id}-tab" into the markup (or aria-label from its label option), so the panel is named before any script runs, and gives tabindex="0" to a panel whose content has nothing focusable, so Tab reaches its text. A panel holding a control gets no extra stop. |
| Colour independence | The active tab is marked by colour and an underline or fill. aria-selected carries the state non-visually and the script keeps it accurate. |
DOMContentLoaded pass: call wsTabsInit() on them once they are in the document.
Tokens
| Token | Used for |
|---|---|
--brand / --brand-soft | 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-interactive | 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. |
.ws-tab-pane / .ws-tab-pane.is-active | A panel, hidden until its tab is active |
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(), wsTabsKeydown() and wsTabsSwitch(): click and arrow-key switching, roving tabindex, panel wiring, and the auto-init |