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.
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
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.
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.
| Part | Class | Role |
|---|---|---|
| Brand | .ws-sidenav__brand | Optional mark and app name at the top; becomes a link when given an href |
| List | .ws-sidenav__list | The items, in journey order, with dividers and headings between groups |
| Item | .ws-sidenav__item | Icon + label + optional badge. An <a> when it navigates, a <button> when it discloses |
| Toggle | .ws-sidenav__toggle | Optional expand/collapse control, pinned to the bottom |
| Footer | .ws-sidenav__footer | Raw 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.
| State | Trigger | Treatment |
|---|---|---|
| Rest | — | Transparent container, --color-ink-secondary icon and label (M3 onSurfaceVariant) |
| Hover | :hover | M3 state layer — currentColor at 8% over the container, not a swatch swap |
| Focus | :focus-visible | State 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 | :active | State layer at 10% |
| Active | active => true | aria-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 |
| Disabled | disabled => true | M3's 38% content opacity, pointer-events: none. Links get aria-disabled + tabindex="-1"; buttons get the native attribute |
| Badge | badge => 37 | Trailing count pill when expanded; anchored to the indicator's top-right corner in the rail |
| Disclosure | expands => true | Caret rotates 180° on aria-expanded="true"; the sublist uses the real hidden attribute, not opacity |
| Tooltip | rail + hover/focus | Not 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 = [])
| Option | Type | Default | Notes |
|---|---|---|---|
variant | string | expanded | expanded | rail. Anything else falls back to expanded |
ariaLabel | string | Primary | Landmark name. Give each nav on a page a distinct one |
brand | array | — | label, sub, icon, href |
collapsible | bool | false | Renders the expand/collapse toggle. Wiring the click is the app's job |
footer | string | '' | Raw HTML pinned to the bottom |
id / class / attrs | string / string / array | — | Element hooks |
Item shape
| Key | Type | Notes |
|---|---|---|
label | string | Required. Always in the DOM — it is the accessible name in both variants |
icon | string | Name from images/icons/; falls back to the Material Symbols ligature if no SVG exists |
href | string | Present → <a>. Absent → <button> |
active | bool | Sets aria-current="page" and the left marker |
disabled | bool | See States |
badge | string|int | Count pill |
expands / open / children | bool / bool / array | Disclosure row. Forces a <button> even with an href |
target, id, class, attrs | — | target="_blank" also sets rel="noopener" |
type | string | divider or heading instead of an item |
Accessibility
- Landmark. Renders a
<nav>witharia-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; aforced-colorsblock maps it toHighlight. - 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
disabledfor anchors, so the component setsaria-disabled="true"andtabindex="-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
#57534Eon#FAFAF9is 7.3:1; active label#2C2416on the#FEE2E2indicator is 12.5:1; badge#FFFFFFon#C73E3Eis 5.0:1. All clear AA. Two earlier pairings did not and were changed: red#C73E3Eon the indicator measured 4.11:1, and a--phase-learnbadge 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 toHighlightunder forced colours.
Tokens consumed
| Token | Used for |
|---|---|
--gray-50 / --gray-100 / --gray-200 | Rail ground, hover ground, borders and dividers |
--color-ink / --color-ink-secondary / --text-faint | Label at hover, rest and disabled |
--phase-learn / --phase-learn-dark | Active marker, badge, brand mark, active label |
--phase-learn-very-light / --phase-learn-light | Active ground and its hover |
--space-1 … --space-4 | Padding, gaps and dividers |
--radius-sm / --radius-md / --radius-full | Rows, marker, badge |
--text-nano / --text-meta / --text-small / --text-body | Headings, children, labels, brand |
--font-body / --font-heading / --font-medium / --font-semibold / --font-bold | Type |
--focus-ring / --focus-ring-offset | Focus state |