App shell navigation
The suite rail and top bar — SideNav, TopNav, AppSwitcherMenu, and the nesting and routing rules.
The shell is the one piece of chrome every internal tool shares, so it is also the piece that
drifts fastest — each app grows its own sidebar, its own header, its own hard-coded dark blue.
SideNav, TopNav and AppSwitcherMenu are the shell; apps compose them and define none of it.
SideNav
The collapsible left rail. Driven entirely by the --sidenav-* token layer, so it re-themes
under data-brand and never needs an app-local colour.
Three shapes, in order of preference:
- Flat
items— one level, no grouping. The default; reach for it first. sections— a labelled group under an overline (e.g.ADMIN). The header is a label, not a destination.- Nested
children— a parent that is itself a link and owns sub-pages, rendered as an indented rail beneath it. Use this when the parent is somewhere you can actually go.
Sections and nesting are not interchangeable. If the group header navigates, it is nesting; if it is only a caption, it is a section.
Exactly one active item
Set active on the current page and nothing else — never on an ancestor. A parent whose child
is current stays unmarked, so exactly one item in the rail carries aria-current="page".
Resolve the active href once, by longest match, so a nested child wins over the parent whose
path prefixes it (/price/engine beats /price). Marking both is the common bug and it reads
to a screen reader as two current pages.
Children are hidden when collapsed
Deliberate, not an oversight. A collapsed rail is icon-only, and a second column of unlabelled icons reads as siblings rather than descendants. Anything that must survive the collapse belongs at the top level.
linkComponent — required in any routed app
SideNav renders a plain <a> by default so it works in Storybook and static contexts. In a
real app that is wrong: a bare anchor triggers a full document load, which drops client state
and re-runs the entire shell on every nav click.
Pass your router's link instead:
import Link from "next/link";
<SideNav items={items} linkComponent={Link} aria-label="Sidebar" />;NavRailItem takes the same escape hatch as as if you are composing a rail by hand. The
collapse toggle stays a <button> either way — it has no href and goes nowhere.
TopNav and AppSwitcherMenu
TopNav is the sticky top band: a brand wordmark, a left slot (conventionally the
AppSwitcherMenu trigger), a fluid centre for a Breadcrumb, and a right slot for icon
buttons and AvatarInitials.
Put wayfinding in the centre and identity on the right. A breadcrumb there and an active item in the rail answer different questions — "where am I in the tree" versus "which area am I in" — so a nested rail does not remove the need for a breadcrumb.
Do / Don't
- Do compose the shell from these three and let the tokens carry the brand.
- Do pass
linkComponentin every routed app, and give the rail anaria-labelyour e2e locators can name. - Don't hand-roll a sidebar with inline styles and literal hex values. That is the exact drift this catalog exists to prevent, and it is invisible until someone re-themes.
- Don't mark a parent active because its child is. Resolve the longest match instead.