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

StateBehavior
Active.is-active plus aria-selected="true". Exactly one tab should start active — the component doesn't enforce it.
HoverColour shift over --transition-fast.
FocusNative 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.
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
classstring''Additional CSS classes

Item options

OptionTypeDefaultPurpose
idstring'tab-' . $indexEmitted as data-tab, and used to build aria-controls as {id}-panel
labelstring''Tab text
iconstringnullLeading Material icon
countintnullBadge count after the label
activeboolfalseInitially selected
disabledboolfalseVisually disabled and click-blocked
gatedboolfalseLock 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.

ConcernBehavior
ARIAThe row is role="tablist"; each button is role="tab" with aria-selected and aria-controls pointing at {id}-panel.
KeyboardEvery 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.
FocusNative focus ring on each button.
PanelsYours 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 independenceThe 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. 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

TokenUsed for
--mainsite-primary / --mainsite-lightActive 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-fastHover 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.

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() — click handling and the auto-init