Sidebar
The dashboard navigation for business and admin. A sidebar exists for one reason: on a surface someone uses every day, destinations must stay visible, not be discovered. Everything below follows from that.
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.
The model it navigates
The sidebar is a picture of the data, and the data has three levels:
person ──belongs to──▸ organisation ──has──▸ event
(one or many) (many, each with its own surfaces)Two of those three are scopes — a place you are working inside — and only one of them is a list of destinations. That is the whole structural decision:
- Organisation is chosen, not navigated. It is the switcher at the top, because a person can belong to more than one and everything below the switcher changes meaning when it changes. Their role changes with it too.
- Event is a scope the sidebar enters. When you open an event, the nav is replaced by that event's nav and a back link appears. It is not a group that expands: an organiser with forty events cannot have forty expandable rows.
- Inside an event the nav becomes that event’s own surfaces — collect, feedback, reviews, promote, settings. They are leaves: each opens a page, none expands further. A single review or a dispute is that page’s business, never a row here.
This is what keeps the never nest two levels rule true against a real model: depth becomes scope, not indentation. A third level of chevrons is a file tree, and a file tree is what a sidebar turns into when nobody makes this decision.
One group in the whole nav, and it has to earn it. Reputation does — reviews, disputes and mentions are one job, read together. Everything else is a flat destination. A second group would be storage, not navigation.
Anatomy
Five regions, in fixed order. Only the nav scrolls — an organiser with forty events must never lose the account or the sign-out by scrolling the nav.
Open XX Marathon of San Francisco under Recent events — the sidebar enters the event scope: the nav is replaced by that event’s surfaces and a back link appears. Switch organisation at the top and the role changes with it — Owner of one, Viewer of the other. Open the account button at the bottom, then switch role and open it again — Billing and Team leave the menu, and Widgets leaves the nav. Links do not navigate here; clicking one moves the current-page marking so you can see it. The demo mirrors the 768px media query in JS because the mock is not the viewport — production uses the media query itself.
Regions & measurements
| Region | Behaviour | Rule |
|---|---|---|
| Org switcher | Pinned top | Only 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 bar | Pinned, event scope only | A back link out of the scope and the event's name and date. Absent at organisation level. |
| Primary action | Pinned, optional | At 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. |
| Navigation | Scrolls | The only scrolling region. Groups one level deep, never two. |
| Account & sign out | Pinned bottom | Name, 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 shadowRows 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.
| Role | Can | Cannot |
|---|---|---|
| Owner | Everything, including billing and deleting the organisation | — |
| Admin | Events, reviews, embeds, invite people | Billing, delete the organisation |
| Editor | Events and reviews | Invite people, embeds, billing |
| Viewer | Read everything | Change 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.
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.
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 the only red in the sidebar, and it is 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 & 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 /logoutcan be fired by any image on any page, and prefetchers will fire it for you. - Last in the region, below the account. It is the only destructive control here, so 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.
- Styled in
--color-destructive, which in this system means irreversible or disruptive, not dangerous. It is the only red in the sidebar.
Billing
Owner only, and it does not belong in the nav. The nav is a list of places the reader works; billing is visited four times a year. Putting it in the sidebar costs a permanent row to save two clicks a quarter — and forces a role check on the most-looked-at region of the screen.
Rules
Analytics · Widgets · Settings
Six to ten top-level destinations, in the order the work happens.
Insights · Exports · Embed · API · Billing
Team · Roles · Webhooks · Audit · Settings
A flat list of everything. Past ten, group it — or admit some of it is a settings page.
- 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"witharia-selectedon both options — arrows, Enter, Escape. Declaring the role without building it is worse than declaring nothing.
theme.css.