Canvas Design System
Main Site Tokens

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.

info This page documents the API and the states. The Material 3 provenance, the role mapping onto Workshopr tokens, and the measured contrast decisions live on Side Navigation in the Redesign track. Read that one for why; read this one for what to pass.

When to Use

Use when: A full-height app shell needs persistent navigation between apps: the installed PWA frame, a kiosk or popup mode, or any surface where 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.
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. 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.

StateTriggerTreatment
RestdefaultTransparent container, --color-ink-secondary icon and label.
Hover:hoverA state layer: currentColor at 8% over the container, not a swatch swap.
Focus:focus-visibleState layer at 10%, plus the platform's sky-blue --focus-ring.
Pressed:activeState layer at 10%.
Active'active' => truearia-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' => true38% 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' => 37Trailing 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' => trueAlways 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 navigatesno href, no expandsRenders a plain <button>. This is how the shell's More entry opens the app launcher.
Tooltiprail, hover or focusBacks 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 = [])

OptionTypeDefaultPurpose
$itemsarray[]Positional and required. Entries in render order; see the item table below.
variantstring'expanded'expanded | rail. Anything else is coerced to expanded.
ariaLabelstring'Primary'Landmark name on the <nav>. Give every nav on a page a distinct one.
brandarraynulllabel, sub, icon, href. With an href it renders as a link, without one as a <div>.
collapsibleboolfalseRenders 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.
footerstring''Raw HTML pinned to the bottom. Printed unescaped, so never build it from user input.
idstringnullID on the <nav>. Pass one if any JS has to find the rail.
classstring''Extra classes, appended after .ws-sidenav and the variant modifier.
attrsarray[]Extra HTML attributes on the <nav>. Every entry renders as key="value".

Item shape

KeyTypePurpose
typestringdivider or heading instead of an item. A heading also needs label; it is aria-hidden and hidden outright in the rail.
labelstringRequired for an item. Always in the DOM, in both variants, because it is the accessible name.
shortstringA 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.
iconstringName 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.
hrefstringPresent renders an <a>; absent renders a <button>.
activeboolSets aria-current="page", the pill indicator, and the filled icon.
disabledboolSee States. Handled differently for links and buttons.
badgestring|intCount pill. Skipped when null or the empty string, so 0 does render.
targetstringLinks only. _blank also sets rel="noopener".
expandsboolDisclosure row. Forces a <button> even when href is set.
openboolRenders the disclosure expanded. Not listed in the design system JSON, but the template reads it.
childrenarraySub-items, same shape. Rendered only alongside expands.
idstringElement ID. On a disclosure it also produces aria-controls and the panel's id, so pass one there.
class / attrsstring / arrayPer-item hooks on the anchor or button.

Accessibility

ConcernBehavior
LandmarkA real <nav> with aria-label. If a page carries more than one nav, every label must differ or the landmark list is undifferentiated.
StructureItems sit in a <ul> / <li>, so AT announces the count and users can jump by item. Dividers carry role="separator".
Current pagearia-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.
KeyboardPlain 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 railVisible, 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 linksaria-disabled="true" plus tabindex="-1" rather than a focusable dead control.
IconsDecorative in every case; the label carries the meaning, so the ligature fallback is aria-hidden.
MotionAll transitions drop under prefers-reduced-motion: reduce.
Disclosure without an idYour 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 propertyResolves toUsed for
--ws-sidenav-surface--gray-50Rail ground
--ws-sidenav-indicator--phase-learn-lightThe active pill
--ws-sidenav-on-indicator--color-inkActive label and icon. Ink rather than red: red on the pill measures 4.11:1 and fails AA.
--ws-sidenav-on-surface--color-ink-secondaryResting label and icon
--ws-sidenav-radius--radius-fullThe pill shape
--ws-sidenav-inset--space-5Item inset from the container edge
--ws-sidenav-item-gap--space-1Gap between destinations
--ws-sidenav-icon-size--icon-lgGlyph box
--ws-sidenav-label-size / --ws-sidenav-rail-label-size--text-small / --text-metaLabel scale per variant
--gray-200Right border and dividers
--focus-ring / --focus-ring-offsetFocus state
--font-body / --font-medium / --font-semiboldFace, resting weight, active weight

CSS Classes

ClassPurpose
.ws-sidenavThe <nav>, carrying the local custom-property layer
.ws-sidenav--expanded / --railThe two variants. Both are emitted, so a shell can swap them at runtime by toggling classes.
.ws-sidenav__brand / __brand-text / __brand-label / __brand-subOptional mark at the top
.ws-sidenav__list / __rowThe <ul> and each <li>
.ws-sidenav__divider / __headingThe two non-item entry types
.ws-sidenav__itemOne destination, with --active, --disabled and --child modifiers. Its ::after is the state layer.
.ws-sidenav__indicatorSizing box in the expanded variant; the 56 by 32 pill in the rail
.ws-sidenav__icon / __icon--filledGlyph, and the active row's FILL axis
.ws-sidenav__label / __badge / __caret / __tipLabel, count pill, disclosure caret, rail tooltip
.ws-sidenav__sublistDisclosure panel, hidden with the native hidden attribute
.ws-sidenav__toggle / __footerCollapse control and the raw-HTML footer slot

Files

FilePurpose
includes/components/helpers.phpws_sidenav() helper function
includes/components/sidenav.phpTemplate: item rendering, icon fallbacks, ARIA wiring
includes/components/components.cssStyles (.ws-sidenav rules), plus the reduced-motion and forced-colors blocks
app/index.phpThe shipping caller: item construction and the collapse toggle's wiring
design-system/redesign/sidenav.phpThe Material 3 provenance, role mapping and measured contrast