All documents

The sidebar and the shell

components/shell/app-shell.tsx — the rail, the header, and the screen. Every screen wears this frame. A tool supplies its own nav and crumbs and gets the same furniture, so moving between tools does not move the furniture.

<SiteShell nav={NAV} crumbs={[{ label: "Coverage" }, { label: "Overview" }]} products={PRODUCTS}>
  {children}
</SiteShell>

Mount it in the tool's layout.tsx, never in a page — mounted at the layout level the rail and header survive a route change instead of remounting.

What you import

ExportFileWhat it is
SiteShell
components/shell/app-shell.tsx
The frame: rail + header + slot
HubShell
components/shell/hub-shell.tsx
The same frame with the tools as its rail
ProductSwitcher
components/shell/product-switcher.tsx
Which tool you are in, and the way to the others
UserMenu · AccountButton
components/shell/account-menu.tsx
The account menu's two triggers — rail footer and chromeless header
SiteHeader
components/shell/site-header.tsx
The 56 px header for pages with no rail
ScopeSelector
components/shell/scope-selector.tsx
The header's one scope control (18-maps-and-scope.md)
ThemeProvider
components/brand/theme-provider.tsx
next-themes pointed at data-theme
Logo (LogoProps)
components/brand/logo.tsx
The mark, per brand, light/dark swapped in CSS

Navigation is DATA, in lib/nav.ts: Product, NavItem, NavSection and Crumb are the four types, productFor(pathname, products) resolves which tool owns a path, and productsAsNav(products) turns the tool list into a rail — which is exactly what HubShell does with it, so the hub keeps no second list.

The measurements

VariableValueWhat it is
--sidebar-width
14rem (224px)
Expanded rail. Fits a two-word label at caption size
--sidebar-width-icon
2.5rem (40px)
Collapsed rail. Exactly one icon button
--header-height
3.5rem (56px)
Both headers, so their rules meet across the fold

The rail is variant="inset" collapsible="icon": it collapses to icons, not to nothing, so the reader never loses their place. The panel carries m-2, which is why a full-viewport route subtracts 1rem (see 11-traps.md).

Anatomy, top to bottom

  1. The mark. Wordmark when expanded, iso when collapsed, linking to the hub. Same height as the page header plus its 1px border. It follows data-brand on its own (01-brand-and-color.md) — the shell passes no brand, so a brand-scoped subtree gets the right mark without the shell knowing.
  2. The product switcher. Which TOOL you are in, and the way to every other one. The tool's icon sits in an accent tint chip; under the name, the workspace in mono caps. Its menu lists every tool, marks the current one with a check, renders soon tools as non-clickable rows, and puts "All tools" under a separator because the hub is not a tool.
  3. The tool's nav groups. Group labels name the subject ("Users", "Permits coverage"), not the tool — the switcher directly above already names that. A rail with one row is not redundant: it is what makes the frame the same frame.
  4. The account menu, pinned to the footer above a border.

Row states

StateHow it renders
Default
Icon + label, 32px tall, 8px radius
Hover
--sidebar-accent (the accent tint)
Active
Same tint, plus font-medium, plus accent-tinted ink
soon
Disabled, 60% opacity, a mono-caps SOON badge, never a link
external
An mark at the end, opens in a new tab, never gets an active state
adminOnly
Not rendered at all for a member. Not disabled — absent

Active resolution is longest-match: a child route beats its parent, exact means only that path, and match adds extra roots. One row is active at a time.

Collapsed, the group labels vanish, so groups after the first gain a top border — the rule does the separating that the labels were doing.

The header

  • The sidebar trigger (the only way to collapse; there is no drag rail).
  • The breadcrumb: where you are. The last crumb is the current page and takes no href — a crumb that links to the page you are on is a dead control. Two crumbs is the norm: the tool, then the screen.
  • One optional slot (headerRight) for a control that scopes the screen's figures. A tool with nothing to scope passes nothing. Never put a scope control on a screen it does not scope — a permit-type selector over a table of people is chrome that lies.

Where a destination belongs

  • In the rail if it is a screen of this tool.
  • On the hub and in the switcher if it is a tool. One list, PRODUCTS, rendered three ways (hub tiles, switcher menu, 404 exit). Never a second list of what exists.
  • Nowhere else. The account menu holds appearance, sign-out, and any switch that belongs to the READER rather than to a tool — feedback mode is the worked example (24-feedback-and-triage.md). It holds no navigation. A link reachable from two places is one to keep in step in two places.

Mobile

Below the mobile breakpoint the rail becomes a Sheet (handled by the sidebar primitive through hooks/use-mobile.ts). Nothing to configure; do not build a second mobile nav.

The 404

There are deliberately no redirects for moved routes. A stale link lands on a 404 that names the moves and lists the tools as exits. A redirect table silently keeps stale links working, and nobody ever updates one.

Two traps that live here

  • SidebarMenuButton with a tooltip cannot be a DropdownMenuTrigger asChild — the switcher and the account menu therefore pass no tooltip. Full explanation in 11-traps.md; the symptom is a menu that opens and closes at random.
  • A background tab freezes CSS transitions at their start value, so measuring the collapsed rail with getComputedStyle returns the expanded width. Bring the tab to the front or disable transitions before measuring.