Tabs
Switches between sibling views without leaving the page.
When to Use
underline (the default, size md) switches whole sections of a page or a workshop: Agenda and Notes, Overview and Details. pill narrows a list that stays below it: All, Exercises, Icebreakers. segmented changes how the same content is shown: grid or list, this week or this month. Name the tabs with nouns in sentence case, and give the row a label that says what it switches ("Workshop sections"). Put a count only on a tab whose label is the thing counted ("Exercises 120"), as on a pill filter; a section tab such as Agenda takes none. Give the row a param ('tab') whenever reload, a shared link or a form in a panel should come back to the open tab.
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.
<?= ws_tabs([
['id' => 'all', 'label' => 'All', 'count' => $counts['all'], 'active' => true],
['id' => 'exercises', 'label' => 'Exercises', 'count' => $counts['exercises']],
], ['id' => 'library-tabs', 'variant' => 'pill', 'param' => 'type', 'label' => 'Library types']) ?>
<?php // ?type=exercises opens Exercises; active marks the tab a bare URL opens on. ?>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. |
param | string | '' | Query-string name for the open tab, conventionally 'tab', with the item ids as its values. It overrides the items' active flags, rides through every GET form in the row's panels as a hidden field, and follows tab clicks in the URL with replaceState. |
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. |
| Ids | Each item id becomes {id}-tab and {id}-panel, and components.js also looks up the bare item id, for older panes written as <div class="ws-tab-pane" id="{id}">. It ignores an element without that class, but keep the bare id free all the same, so the next reader is not left to work that out: a field named like a tab takes it, since ws_input('notes') renders id="notes" beside a Notes tab. Give the field its own id ('id' => 'workshop-notes') and keep its name for the query string. In a fragment that shares a page with other content, prefix the item ids with what the row belongs to (workshop-agenda, workshop-notes). |
| Counts | The count is plain text inside the tab, so it joins the tab's name: "Exercises 120". That reads well only when the label is the noun counted, which is why counts belong on filter tabs and not on section tabs. |
| 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 |