Components

Side nav

Side nav for business and admin — the authenticated organiser shell. On surfaces people use every day, destinations stay visible, not discovered. Full-page sample: Side nav template.

Not the same component as the navigation menu. That one is for www — a visitor who does not yet know what “Debriefs” means, so every link carries a description. Here the reader already knows, and a description on every row is friction repeated a hundred times a week. Shared: the disclosure logic, the icon tile, the keyboard rules. Not shared: the model.

Live sample

The organiser rail as business renders it: the brand, New event, one tree per organisation (settings, series, editions), then account, billing, support, language and log out. It is the Side nav shell template, embedded. Open the template on its own to resize the rail and try the mobile drawer below 768px.

Recipe & template

import { appShell } from '@sportingscouter/ui';
const s = appShell({ mode: 'sideNav' });

Visual slots only — scope switching, drawer, and org list behaviour live in the app. Reference markup: page.css (.sn) on the template.

Apps that render the product rail import @sportingscouter/ui/shell-product.css after the theme. Every rule in it is scoped under :where(.appshell--product), a class appShell() adds in sideNav mode, so it never touches the docs navigation, which reuses the same class names. The slots railInner, act, rule, groupHd, chev, list, orgMeta, icon, avatar, foot and footLocale on sideNav(), and mobbar, mobbrand, burger, productMain and bodyInner on appShell(), return the class names that file styles. The template above still carries its own copy of the same design as .sn__* in page.css.

The model it navigates

The rail is a picture of context — org, event, edition — plus session actions. The edition workspace is a separate layer in the main column.

person  ──▸  organisation  ──▸  event  ──▸  edition
              │ COMPANY settings          │ workspace header: Event — edition
              │ (rail: Settings per org)  │ tabs: overview · verified · organic · promote · settings
              └ Billing (user account, foot)
  • Organisation — chosen in the rail (tree or switcher). Settings at the top of each org panel opens company configuration, not edition work.
  • Event · edition — selected in the rail tree. Selecting an edition loads the workspace; the rail does not list tab destinations as rows.
  • Workspace chrome — one title line (Event name — edition), then fixed-order tabs in the page. Canonical markup: Side nav shell.

Company (organisation settings)

Outside the per-edition workspace. Opened from Settings on each org in the rail — not from workspace Settings (that tab is edition/event config).

  • /profile
  • /team
  • /review-settings
  • /dispute-rules

Edition workspace

Header line: Event name — edition when an edition is open; organisation name only when company Settings is selected. Tabs hide in company scope.

Tab slugUI labelNotes
overviewOverviewEdition home
verified-feedbackVerified feedbackVerified debrief / post-event feedback
organic-feedbackPublic feedbackSocial Debrief plus spontaneous reviews on feedback.sportingscouter.com
promotePromoteShare and campaigns
settingsSettingsEdition/event — not company

Anatomy

Five regions, in fixed order — see the live sample. Only the nav scrolls; an organiser with forty events must never lose the account or the sign-out by scrolling the nav.

Regions & measurements

RegionBehaviourRule
Org switcherPinned topOnly when the reader can belong to more than one organisation. One org, no switcher — the org name becomes a plain header. The collapse control sits beside it, as an icon.
Scope barPinned, event scope onlyA back link out of the scope and the event's name and date. Absent at organisation level.
Primary actionPinned, optionalAt most one, and it belongs to the current scope — New event in the org, and none inside an event — an event’s actions belong on the page they act on. A second belongs on the page it acts on.
NavigationScrollsThe only scrolling region. Groups one level deep, never two.
Account & sign outPinned bottomName, role, a popup of account links — and sign out as its own visible row. Not role="menu" — see below.
expanded 256pxrail 64pxrow 44pxicon 18pxhairline right borderno shadow

Rows are 44px, not the 32px a dense dashboard invites. Density changes the padding around the nav, never the row height — 44px is the floor and a target does not shrink because a preference changed.

Behaviour

Current page

Three signals, never one: a background fill, semibold weight, and a 2px rule in Scout Blue — plus aria-current="page", which is what actually tells a screen reader. If the current page sits inside a group, that group is expanded on load. A collapsed group hiding the page you are on is the single most common sidebar defect.

Groups

A button with aria-expanded and a panel — the same disclosure as Collapse, and the chevron swaps rather than rotates for the same reason. Groups make a long list scannable; they are not storage. If a group is collapsed by default and stays collapsed, its contents are not top-level navigation.

The rail

Collapsing to 64px is for wide data tables, not a default. Two conditions, both hard:

  • Every top-level item has a distinct icon. Two items sharing an icon are indistinguishable in the rail — if you cannot name a distinct one, the item does not belong at top level.
  • Labels leave the page, never the accessibility tree. They are visually hidden, not display:none, so the link keeps its name.

Clicking a group in the rail widens the sidebar rather than opening a flyout. A flyout is a second navigation pattern with its own keyboard model, for a state the reader chose to be in temporarily.

Width does not animate

Collapse is instant. Animating a 192px width change reflows the entire dashboard on every frame, and Motion allows transform in the sheet and the spinner only. The drawer sliding in from the edge is a sheet, and uses the existing anim-slide-in.

Below 768px

The sidebar becomes an off-canvas drawer behind the system's one breakpoint. It is a modal layer, so it takes the modal contract in full: a scrim, a focus trap, Escape to close, focus returned to the trigger that opened it, and the drawer closing on navigation — the reader asked for a page, not for the menu to stay open.

@media (max-width: 767px) {
  .sidebar { position: fixed; inset: 0 auto 0 0; z-index: var(--z-index-modal) }
  .sidebar[hidden] { display: none }
  .sidebar:not([hidden]) { animation: anim-slide-in 180ms var(--ease-enter) both }
}

Role

Business accounts are shared. The person reading the sidebar may be the owner who pays, a colleague who answers reviews, or a timing contractor with read-only access for one weekend. The role is shown because permissions are otherwise invisible. If Billing is missing from someone's account menu, the badge next to their name is the only thing that explains why — without it they file a bug.

RoleCanCannot
OwnerEverything, including billing and deleting the organisation—
AdminEvents, reviews, embeds, invite peopleBilling, delete the organisation
EditorEvents and reviewsInvite people, embeds, billing
ViewerRead everythingChange anything
  • The badge is a Badge — uppercase, subtle fill, never a colour that reads as a status. A role is not good or bad news.
  • The role belongs to the pairing, not the person. The same account can be Owner of one organisation and Viewer of another, so the badge sits with the account and re-reads on every org switch.
  • Never hide the role to save space. In the rail the person’s name goes and the badge stays, shortened to its initial — O, A, E, V. The full word stays in the accessibility tree, so the button is still announced as “Ana Rocha, Owner”. An initial is not self-explanatory, which is the price of the rail; the word returns the moment the sidebar does.

Hide or disable

Two different facts, and mixing them is what makes permissioned UI feel broken.

Hide

What this role can never do. A Viewer does not need to know a Billing page exists; showing it is an advert for a door that will not open.

Disable

What is unavailable right now, with the reason attached — “Available once your first event is published”. Temporary, and explainable in one line.

Never a third option: an item that is visible, enabled, and errors on click. That is the pattern this rule exists to prevent.

Hiding is not a permission. Every gated route and mutation is checked on the server. The role in the sidebar decides what is drawn, nothing more — a hidden link is one URL away from being visited.

Account

Pinned to the bottom, above nothing. It carries the name, the role, and a popup containing the account, not the work — that is the line that decides what goes in it. Events and reviews are the work and live in the nav; profile, team and billing are the account and live here.

Sign out is a row, not a submenu

It sits below the account button as its own visible row, because a person who wants to leave should not have to open something to find the exit — particularly on a shared organiser account handed between a race director and a timing contractor. It is styled like any other sidebar row, not in red: signing out is routine and reversible, and red is reserved for actions that destroy or lose data. It sits below the account rather than above it, so a misreach lands on Ana Rocha and not on the way out.

It is not a role="menu"

Every item in it is a destination. role="menu" is for commands, and applying it to links does three concrete kinds of damage:

  • It replaces the link role with menuitem, so a screen reader stops announcing that the item goes somewhere.
  • It takes over the arrow keys, and it suppresses the browser's own affordances — middle-click, Cmd-click, Open in new tab. People open billing in a new tab constantly.
  • It obliges a full APG keyboard implementation for what is a disclosure with a list inside it.

So: a button with aria-expanded and a list of real <a> elements — no commands inside it at all, now that sign out is its own row. Escape closes and returns focus; a click outside closes.

<button aria-expanded="false" aria-controls="acct">
  Ana Rocha <span class="badge">Owner</span>
</button>

<div id="acct" hidden>
  <ul>
    <li><a href="/profile">Your profile</a></li>
    {#if role === 'owner'}
      <li><a href="/billing">Billing &amp; plan</a></li>
    {/if}
  </ul>
</div>

<form method="post" action="/sign-out">
  <button type="submit">Sign out</button>
</form>

Sign out

  • A POST, never a link. A GET /logout can be fired by any image on any page, and prefetchers will fire it for you.
  • Last in the region, below the account — nothing sits under it to be missed for it.
  • No confirmation dialog. Signing out is reversible — you sign back in. Confirmations are for things that are not.
  • Not destructive. Same neutral row treatment as the nav links. destructive is for deleting a review, hiding a flag, removing an event — things that need a second thought. Ending a session does not.

Billing

User-scoped, not org or edition. In the shell template foot order is account, billing, support, then locale switcher last (top to bottom) — not inside the org tree or workspace tabs. Owner-only in product apps; the rail should not duplicate company billing next to every org.

Rules

Event — edition · five workspace tabs in the page
Do

Context in the rail; title + tabs in the workspace header.

Overview · Collect · Feedback · Reviews · Analytics · Widgets · Settings
all as sidebar rows
Don't

Duplicate workspace tabs in the sidebar — or mix company settings with edition settings in one list.

  • Never nest two levels. A sidebar with expandable sub-sub-items is a file tree. When the model is deeper than two levels, the third level becomes a scope the sidebar enters — and the level below that is the page's own tabs.
  • Never mark the current page with colour alone — the system's own rule, and the one most often broken here.
  • Counts are facts, not decoration. A number next to Flagged earns its place; one next to Settings does not.
  • The sidebar does not scroll the page. The nav region scrolls inside itself; the pinned regions stay put.
  • No shadow. A hairline separates it from the content, like every other plane in the system.

Markup & accessibility

A sidebar is a set of links inside a landmark. It is not role="menu": that role is for commands, and it takes over the arrow keys, which readers expect to scroll a forty-item list.

<aside class="sidebar">
  <button aria-haspopup="listbox" aria-expanded="false">Star Events</button>

  <nav aria-label="Main">
    <ul>
      <li><a href="/" aria-current="page">Home</a></li>
      <li><a href="/events">Events</a></li>
      <li>
        <button aria-expanded="true" aria-controls="g-reputation">Reputation</button>
        <ul id="g-reputation">
          <li><a href="/reputation/reviews">Reviews</a></li>
          <li><a href="/reputation/disputes">Disputes</a></li>
        </ul>
      </li>
    </ul>
  </nav>

  <button aria-haspopup="menu" aria-expanded="false">Ana Rocha</button>
</aside>
  • <nav aria-label="Main"> — a label is required when a page has more than one landmark of the same type, and a dashboard always does.
  • Tab moves through links. No arrow-key hijacking, no roving tabindex.
  • The org switcher is a listbox — choosing between things. The account popup is not a menu; see Account.
  • In the rail, labels stay in the accessibility tree. A tooltip is an addition, never the only name.
  • The org list is a real role="listbox" with aria-selected on both options — arrows, Enter, Escape. Declaring the role without building it is worse than declaring nothing.
Sporting Scouter Design System
Every token on this site is generated from theme.css.