Design System
Patterns & Recipes

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.

Page content. The rail owns wayfinding; the page owns everything right 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 linkComponent in every routed app, and give the rail an aria-label your 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.

On this page