Canvas Design System
Main Site Tokens

Side Navigation

The vertical rail an app shell wears down its left edge — ws_sidenav(). Built for the chromeless window, where there is no browser chrome and no horizontal platform nav, so this is the only way between apps.

info This is the Material 3 navigation rail. M3 supplies the geometry and the interaction model — the pill-shaped active indicator, 24dp icons, a 56×32 indicator in the rail and a 56dp-tall pill when expanded, and state layers at 8% / 10% instead of flat hover swatches. Workshopr supplies every colour: secondaryContainer--phase-learn-light, onSecondaryContainer--color-ink, onSurfaceVariant--color-ink-secondary. Red Unification and platform-tokens.css outrank an imported palette, so none of Material's own colour roles ship. (Ink rather than red on the indicator is also the more faithful M3 reading — Material's light theme puts a near-black tone on the tonal pill — and red measured 4.11:1 there, under the AA floor.)

When to Use

Use when: A full-height app shell needs persistent navigation between apps — the chromeless window, a kiosk or popup mode, or any surface where ws_header()'s horizontal nav has been suppressed. It pairs with App Bar: the rail moves you between apps, the bar acts on the open document.
Don't use when: The page is an ordinary web page — that already has ws_header(), and two navs competing for the same job is worse than either alone. Don't use it for in-page section links (use Tabs) or for a trail back up a hierarchy (use Breadcrumb).

Anatomy

Four regions, top to bottom. Only the list is required.

PartClassRole
Brand.ws-sidenav__brandOptional mark and app name at the top; becomes a link when given an href
List.ws-sidenav__listThe items, in journey order, with dividers and headings between groups
Item.ws-sidenav__itemIcon + label + optional badge. An <a> when it navigates, a <button> when it discloses
Toggle.ws-sidenav__toggleOptional expand/collapse control, pinned to the bottom
Footer.ws-sidenav__footerRaw HTML slot for an account chip or version string

Variants

Two, matching M3's collapsed and expanded rail. Labels show in both — M3 keeps them, and so do we. What changes is where the label sits and what the active indicator wraps.

Expanded — 240px (default)

Icon beside label. The 56px pill is the item container, inset 20px per M3's expanded indicator.

Rail — 80px

M3's collapsed rail: label under the icon, 60px destinations, and a 56×32 indicator around the icon alone.

<?= ws_sidenav([
    ['label' => 'Home',       'icon' => 'home',              'href' => '/'],
    ['label' => 'Strategize', 'icon' => 'knight',            'href' => '/strategist/app.php', 'active' => true],
    ['label' => 'Plan',       'icon' => 'calendar_view_day', 'href' => '/planner/'],
    ['label' => 'Facilitate', 'icon' => 'groups',            'href' => '/facilitator/'],
    ['label' => 'Synthesize', 'icon' => 'hub',               'href' => '/synthesize/'],
    ['type'  => 'divider'],
    ['label' => 'Library',      'icon' => 'auto_stories', 'href' => '/library/'],
    ['label' => 'Rescue Cards', 'icon' => 'stacks',       'href' => '/library/collections/', 'badge' => 37],
    ['label' => 'More', 'icon' => 'more_horiz', 'expands' => true, 'id' => 'navMore', 'children' => [...]],
], ['variant' => 'rail']) ?>

States

Every state below is rendered live. Hover and focus are interactive — tab through the rail to see the focus ring and the tooltip appear together.

StateTriggerTreatment
RestTransparent container, --color-ink-secondary icon and label (M3 onSurfaceVariant)
Hover:hoverM3 state layer — currentColor at 8% over the container, not a swatch swap
Focus:focus-visibleState layer at 10%, plus the sky-blue --focus-ring. M3 has no visible focus ring of its own, so the platform's is kept — a keyboard user needs it
Pressed:activeState layer at 10%
Activeactive => truearia-current="page" and the M3 pill indicator. Expanded: the 56px item container fills. Rail: only the 56×32 pill around the icon fills. The indicator is a filled shape, so "current" is not signalled by hue alone
Disableddisabled => trueM3's 38% content opacity, pointer-events: none. Links get aria-disabled + tabindex="-1"; buttons get the native attribute
Badgebadge => 37Trailing count pill when expanded; anchored to the indicator's top-right corner in the rail
Disclosureexpands => trueCaret rotates 180° on aria-expanded="true"; the sublist uses the real hidden attribute, not opacity
Tooltiprail + hover/focusNot an M3 feature. Kept because our destination names are longer than the short words an M3 rail assumes, so a truncated label must stay recoverable. Suppressed entirely when expanded

API

ws_sidenav(array $items, array $options = [])

OptionTypeDefaultNotes
variantstringexpandedexpanded | rail. Anything else falls back to expanded
ariaLabelstringPrimaryLandmark name. Give each nav on a page a distinct one
brandarraylabel, sub, icon, href
collapsibleboolfalseRenders the expand/collapse toggle. Wiring the click is the app's job
footerstring''Raw HTML pinned to the bottom
id / class / attrsstring / string / arrayElement hooks

Item shape

KeyTypeNotes
labelstringRequired. Always in the DOM — it is the accessible name in both variants
iconstringName from images/icons/; falls back to the Material Symbols ligature if no SVG exists
hrefstringPresent → <a>. Absent → <button>
activeboolSets aria-current="page" and the left marker
disabledboolSee States
badgestring|intCount pill
expands / open / childrenbool / bool / arrayDisclosure row. Forces a <button> even with an href
target, id, class, attrstarget="_blank" also sets rel="noopener"
typestringdivider or heading instead of an item

Accessibility

  • Landmark. Renders a <nav> with aria-label. If a page carries more than one nav, every label must differ or screen-reader users get an undifferentiated list.
  • List semantics. Items sit in a <ul>/<li>, so AT announces "list, 8 items" and users can jump by item.
  • Current page. aria-current="page", not just a colour class. M3's indicator is a filled pill rather than a tint, so the state reads as a shape and survives greyscale; a forced-colors block maps it to Highlight.
  • Keyboard. Plain Tab order — these are links in a landmark, which is what APG prescribes for site navigation. No roving tabindex, because arrow-key trapping in a nav surprises more users than it helps. The disclosure is a real button: Enter and Space both toggle it.
  • The rail's labels. Visible, as M3 prescribes — an icon-only rail forces everyone to learn a glyph vocabulary. Where 80px truncates a longer name the tooltip backs it up on hover and focus, so it is reachable from the keyboard. The tooltip is aria-hidden: it repeats the label, and exposing it would double-read.
  • Disabled links. HTML has no disabled for anchors, so the component sets aria-disabled="true" and tabindex="-1" rather than leaving a focusable dead control.
  • Icons. Decorative in every case — the label carries the meaning, so SVGs render inline and the ligature fallback is aria-hidden.
  • Motion. All transitions drop under prefers-reduced-motion: reduce.
  • Contrast, measured not assumed. Rest label #57534E on #FAFAF9 is 7.3:1; active label #2C2416 on the #FEE2E2 indicator is 12.5:1; badge #FFFFFF on #C73E3E is 5.0:1. All clear AA. Two earlier pairings did not and were changed: red #C73E3E on the indicator measured 4.11:1, and a --phase-learn badge measured 3.82:1.
  • The indicator is a tint, so it is never the only cue. At 1.17:1 against the surface, M3's tonal pill is deliberately quiet — it is backed by a weight change to semibold and by aria-current="page", and it maps to Highlight under forced colours.

Tokens consumed

TokenUsed for
--gray-50 / --gray-100 / --gray-200Rail ground, hover ground, borders and dividers
--color-ink / --color-ink-secondary / --text-faintLabel at hover, rest and disabled
--phase-learn / --phase-learn-darkActive marker, badge, brand mark, active label
--phase-learn-very-light / --phase-learn-lightActive ground and its hover
--space-1--space-4Padding, gaps and dividers
--radius-sm / --radius-md / --radius-fullRows, marker, badge
--text-nano / --text-meta / --text-small / --text-bodyHeadings, children, labels, brand
--font-body / --font-heading / --font-medium / --font-semibold / --font-boldType
--focus-ring / --focus-ring-offsetFocus state