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
| Export | File | What 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
| Variable | Value | What 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
- 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-brandon 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. - 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
soontools as non-clickable rows, and puts "All tools" under a separator because the hub is not a tool. - 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.
- The account menu, pinned to the footer above a border.
Row states
| State | How 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
SidebarMenuButtonwith atooltipcannot be aDropdownMenuTrigger asChild— the switcher and the account menu therefore pass no tooltip. Full explanation in11-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
getComputedStylereturns the expanded width. Bring the tab to the front or disable transitions before measuring.