Side Navigation
The vertical rail down an app shell's left edge: the only way between apps when there is no browser chrome and no horizontal header nav.
When to Use
ws_header()'s horizontal nav has been suppressed. It pairs with ws_app_bar(), which acts on the open document while the rail moves between apps.
ws_header(), and two navs competing for the same job is worse than either alone. For in-page sections use Tabs; for a trail back up a hierarchy use Breadcrumb.
Variants
Two. Labels show in both: what changes is where the label sits and what the active indicator wraps. Any value other than expanded or rail is silently coerced to expanded, so a typo degrades rather than breaking.
Expanded, 240px (default)
Icon beside label. The 56px pill is the item container itself, inset 20px from the edge.
Rail, 80px
Label under the icon, 60px destinations, and a 56 by 32 indicator around the icon alone. Hover or focus a destination: a tooltip backs up any label the width has truncated.
<?= ws_sidenav([
['label' => 'Home', 'icon' => 'home', 'href' => '/app/'],
['label' => 'Strategize', 'icon' => 'knight', 'href' => '/app/?view=strategize', 'active' => true],
['label' => 'Plan', 'icon' => 'calendar_view_day', 'href' => '/app/?view=plan'],
['type' => 'divider'],
['label' => 'Library', 'icon' => 'auto_stories', 'href' => '/app/?view=library'],
['label' => 'Rescue Cards', 'icon' => 'stacks', 'href' => '/app/?view=cards', 'badge' => 37],
], ['variant' => 'rail']) ?>With a brand, a collapse toggle, and a footer
The three optional regions. footer takes raw HTML and is printed unescaped, so never pass anything a user typed. collapsible renders the toggle button but wires nothing: the shell owns which variant is showing, since that choice has to persist.
<?= ws_sidenav($navItems, [
'id' => 'appSidenav',
'variant' => 'rail',
'ariaLabel' => 'Sections',
'collapsible' => true,
]) ?>
<script>
// The component ships the button; the shell decides what it does and remembers it.
var nav = document.getElementById('appSidenav');
nav.querySelector('.ws-sidenav__toggle').addEventListener('click', function () {
var next = nav.classList.contains('ws-sidenav--rail') ? 'expanded' : 'rail';
nav.classList.toggle('ws-sidenav--rail', next === 'rail');
nav.classList.toggle('ws-sidenav--expanded', next !== 'rail');
localStorage.setItem('workshopr_app_rail', next);
});
</script>States
Every state below is live. Hover and focus are real: tab into the rail to see the focus ring and the state layer together.
| State | Trigger | Treatment |
|---|---|---|
| Rest | default | Transparent container, --color-ink-secondary icon and label. |
| Hover | :hover | A state layer: currentColor at 8% over the container, not a swatch swap. |
| Focus | :focus-visible | State layer at 10%, plus the platform's sky-blue --focus-ring. |
| Pressed | :active | State layer at 10%. |
| Active | 'active' => true | aria-current="page", the filled pill indicator, semibold label, and the icon switched to Material's filled style. Expanded fills the whole item; rail fills only the pill around the icon. |
| Disabled | 'disabled' => true | 38% content opacity and pointer-events: none. A link also gets aria-disabled="true" and tabindex="-1", since HTML has no disabled for anchors; a button gets the native attribute. |
| Badge | 'badge' => 37 | Trailing count pill when expanded; anchored to the indicator's top-right corner in the rail. Escaped, so a string works as well as a number. |
| Disclosure | 'expands' => true | Always a <button>, even with an href. Caret rotates on aria-expanded="true"; the sublist uses the real hidden attribute. Give the item an id or aria-controls is omitted and the panel is unlinked. |
| Acts rather than navigates | no href, no expands | Renders a plain <button>. This is how the shell's More entry opens the app launcher. |
| Tooltip | rail, hover or focus | Backs up a truncated label. Suppressed entirely when expanded, and aria-hidden in both, since it only repeats the label. |
Real-World Usage
The consolidated app shell, app/index.php. Items are built from the view registry so the active state is derived from ?view= rather than hand-set per page, which is what stops the rail and the page disagreeing.
<?php
$navItems = [];
foreach (['home', 'strategize', 'plan', 'facilitate', 'synthesize'] as $k) {
$navItems[] = [
'label' => $views[$k]['label'],
'icon' => $views[$k]['icon'],
'href' => '/app/?view=' . $k,
'active' => $view === $k,
];
}
$navItems[] = ['type' => 'divider'];
// More acts rather than navigates: no href and no `expands`, which is the
// combination that renders a plain push control.
$navItems[] = ['label' => 'More', 'icon' => 'more_horiz', 'id' => 'appMoreBtn'];
?>
<?= ws_sidenav($navItems, [
'id' => 'appSidenav',
'variant' => 'rail',
'ariaLabel' => 'Sections',
'collapsible' => true,
]) ?>Every M3 metric is exposed as a custom property on .ws-sidenav, so a shell can retune width, item height, indicator size, inset, icon size or label scale without forking the component. Override them on the element, not in the component's own rules.
#appSidenav {
--ws-sidenav-width: 200px;
--ws-sidenav-item-height: 48px;
--ws-sidenav-label-size: var(--text-meta);
}Options
ws_sidenav(array $items, array $options = [])
| Option | Type | Default | Purpose |
|---|---|---|---|
$items | array | [] | Positional and required. Entries in render order; see the item table below. |
variant | string | 'expanded' | expanded | rail. Anything else is coerced to expanded. |
ariaLabel | string | 'Primary' | Landmark name on the <nav>. Give every nav on a page a distinct one. |
brand | array | null | label, sub, icon, href. With an href it renders as a link, without one as a <div>. |
collapsible | bool | false | Renders the expand/collapse button, with aria-expanded and the matching label pre-set for the current variant. Wiring the click is the caller's job. |
footer | string | '' | Raw HTML pinned to the bottom. Printed unescaped, so never build it from user input. |
id | string | null | ID on the <nav>. Pass one if any JS has to find the rail. |
class | string | '' | Extra classes, appended after .ws-sidenav and the variant modifier. |
attrs | array | [] | Extra HTML attributes on the <nav>. Every entry renders as key="value". |
Item shape
| Key | Type | Purpose |
|---|---|---|
type | string | divider or heading instead of an item. A heading also needs label; it is aria-hidden and hidden outright in the rail. |
label | string | Required for an item. Always in the DOM, in both variants, because it is the accessible name. |
short | string | A shorter name for widths the full one cannot survive, such as a rail laid out as a mobile bottom bar. Both labels ship in the DOM: .ws-sidenav__label--short is hidden by default, and the layout that wants it shows it and hides .ws-sidenav__label--long. The item takes label as its aria-label, so the accessible name never shortens with the screen. Ignored when it matches label. |
icon | string | Name from images/icons/. Falls back to the Material Symbols ligature when no SVG exists, so a missing asset never renders an iconless row. The active row prefers a {name}-fill.svg and otherwise uses the font's FILL axis. |
href | string | Present renders an <a>; absent renders a <button>. |
active | bool | Sets aria-current="page", the pill indicator, and the filled icon. |
disabled | bool | See States. Handled differently for links and buttons. |
badge | string|int | Count pill. Skipped when null or the empty string, so 0 does render. |
target | string | Links only. _blank also sets rel="noopener". |
expands | bool | Disclosure row. Forces a <button> even when href is set. |
open | bool | Renders the disclosure expanded. Not listed in the design system JSON, but the template reads it. |
children | array | Sub-items, same shape. Rendered only alongside expands. |
id | string | Element ID. On a disclosure it also produces aria-controls and the panel's id, so pass one there. |
class / attrs | string / array | Per-item hooks on the anchor or button. |
Accessibility
| Concern | Behavior |
|---|---|
| Landmark | A real <nav> with aria-label. If a page carries more than one nav, every label must differ or the landmark list is undifferentiated. |
| Structure | Items sit in a <ul> / <li>, so AT announces the count and users can jump by item. Dividers carry role="separator". |
| Current page | aria-current="page", not just a colour class. The indicator is a filled shape rather than a tint, and a forced-colors block maps it to Highlight. |
| Keyboard | Plain tab order, which is what APG prescribes for site navigation. No roving tabindex, because arrow-key trapping in a nav surprises more people than it helps. The disclosure is a real button, so Enter and Space both toggle it. |
| Labels in the rail | Visible, not icon-only. Where 80px truncates a name, the tooltip appears on hover and focus, so it is reachable from the keyboard. The tooltip is aria-hidden because it repeats the label. |
| Disabled links | aria-disabled="true" plus tabindex="-1" rather than a focusable dead control. |
| Icons | Decorative in every case; the label carries the meaning, so the ligature fallback is aria-hidden. |
| Motion | All transitions drop under prefers-reduced-motion: reduce. |
| Disclosure without an id | Your responsibility. Omit id on an expands item and both aria-controls and the panel's id are dropped, leaving the button and its sublist unconnected for AT. |
Tokens
The component defines its own --ws-sidenav-* layer on .ws-sidenav and resolves each one to a platform token. Retune the local layer per shell; never hardcode past it.
| Local property | Resolves to | Used for |
|---|---|---|
--ws-sidenav-surface | --gray-50 | Rail ground |
--ws-sidenav-indicator | --phase-learn-light | The active pill |
--ws-sidenav-on-indicator | --color-ink | Active label and icon. Ink rather than red: red on the pill measures 4.11:1 and fails AA. |
--ws-sidenav-on-surface | --color-ink-secondary | Resting label and icon |
--ws-sidenav-radius | --radius-full | The pill shape |
--ws-sidenav-inset | --space-5 | Item inset from the container edge |
--ws-sidenav-item-gap | --space-1 | Gap between destinations |
--ws-sidenav-icon-size | --icon-lg | Glyph box |
--ws-sidenav-label-size / --ws-sidenav-rail-label-size | --text-small / --text-meta | Label scale per variant |
--gray-200 | Right border and dividers | |
--focus-ring / --focus-ring-offset | Focus state | |
--font-body / --font-medium / --font-semibold | Face, resting weight, active weight | |
CSS Classes
| Class | Purpose |
|---|---|
.ws-sidenav | The <nav>, carrying the local custom-property layer |
.ws-sidenav--expanded / --rail | The two variants. Both are emitted, so a shell can swap them at runtime by toggling classes. |
.ws-sidenav__brand / __brand-text / __brand-label / __brand-sub | Optional mark at the top |
.ws-sidenav__list / __row | The <ul> and each <li> |
.ws-sidenav__divider / __heading | The two non-item entry types |
.ws-sidenav__item | One destination, with --active, --disabled and --child modifiers. Its ::after is the state layer. |
.ws-sidenav__indicator | Sizing box in the expanded variant; the 56 by 32 pill in the rail |
.ws-sidenav__icon / __icon--filled | Glyph, and the active row's FILL axis |
.ws-sidenav__label / __badge / __caret / __tip | Label, count pill, disclosure caret, rail tooltip |
.ws-sidenav__sublist | Disclosure panel, hidden with the native hidden attribute |
.ws-sidenav__toggle / __footer | Collapse control and the raw-HTML footer slot |
Files
| File | Purpose |
|---|---|
includes/components/helpers.php | ws_sidenav() helper function |
includes/components/sidenav.php | Template: item rendering, icon fallbacks, ARIA wiring |
includes/components/components.css | Styles (.ws-sidenav rules), plus the reduced-motion and forced-colors blocks |
app/index.php | The shipping caller: item construction and the collapse toggle's wiring |
design-system/redesign/sidenav.php | The Material 3 provenance, role mapping and measured contrast |