Canvas Design System
Main Site Tokens

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

  1. Welcome 10 min
  2. 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

StateBehavior
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.
HoverColour shift over --transition-interactive.
FocusThe 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.
ScrollableOn 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

OptionTypeDefaultPurpose
$itemsarray[]Positional array of tab definitions
idstring'ws-tabs-' . uniqid()Container ID. Set it explicitly if JS needs a handle.
variantstring'underline'underline | pill | segmented
sizestring'md'sm | md | lg
scrollablebooltrueHorizontal scroll instead of wrapping
labelstring''The tablist's aria-label. Set it when no visible heading names the row.
classstring''Additional CSS classes

Item options

OptionTypeDefaultPurpose
idstring'tab-' . $indexEmitted 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.
labelstring''Tab text
iconstringnullLeading Material icon
countintnullBadge count after the label
activeboolfalseInitially selected
disabledboolfalseA disabled button: dimmed, click-blocked, and skipped by Tab and the arrow keys
gatedboolfalseLock icon for premium gating
info 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.

ConcernBehavior
ARIAThe 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.
KeyboardRoving 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.
FocusThe platform focus ring marks the focused tab; wsTabsSwitch() keeps tabindex in step when a page switches tabs from its own code.
Panelsws_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 independenceThe active tab is marked by colour and an underline or fill. aria-selected carries the state non-visually and the script keeps it accurate.
info Limits. The arrow keys follow left-to-right order; there is no right-to-left mirroring. Tab rows rendered by a page's own script after load (Synthesize builds its own) are not bound by the DOMContentLoaded pass: call wsTabsInit() on them once they are in the document.

Tokens

TokenUsed for
--brand / --brand-softActive tab colour and its underline or fill
--bg-surfaceSegmented-variant active segment
--gray-50 / --gray-100 / --gray-200Track, hover fill, and the underline rule
--gray-400 / --gray-500Inactive label and the disabled state
--text-dark / --text-inverseLabel colours
--radius-full / --radius-lgPill and segmented corners
--shadow-smSegmented active segment elevation
--text-micro / --text-meta / --text-small / --text-ui / --text-h4Label and count type scale across sizes
--font-body / --font-medium / --font-semiboldFace, and the weight bump on the active tab
--transition-interactiveHover and active transitions

CSS Classes

ClassPurpose
.ws-tabsOuter container. Auto-initialised on DOMContentLoaded.
.ws-tabs--pill / --segmentedVariants (underline is the default and emits nothing)
.ws-tabs--sm / --lgSize modifiers
.ws-tabs--scrollableHorizontal overflow scrolling
.ws-tabs__navThe role="tablist" row
.ws-tabs__btnEach tab button
.ws-tabs__btn.is-activeSelected tab
.ws-tabs__btn.is-disabledDisabled tab
.ws-tabs__btn.is-gatedGated tab
.ws-tabs__iconLeading icon
.ws-tabs__countBadge count
.ws-tabs__lock / .ws-tabs__lock-iconGated lock indicator
.ws-tabs__labelLabel wrapper — emitted but has no CSS rule. A hook, not an active style.
.ws-tab-pane / .ws-tab-pane.is-activeA panel, hidden until its tab is active

Files

FilePurpose
includes/components/helpers.phpws_tabs() helper function
includes/components/tabs.phpTemplate — markup and ARIA wiring
includes/components/components.cssStyles (.ws-tabs rules)
includes/components/components.jswsTabsInit(), wsTabsKeydown() and wsTabsSwitch(): click and arrow-key switching, roving tabindex, panel wiring, and the auto-init