The whole-app chrome for a product built on the kit: a collapsible nav sidebar, a topbar with title/search/actions/user slots, and a padded content area that renders children.
The canon behind it. method/design-language/08-navigation-and-shell.md §A1 (08-navigation-and-shell.md:14, chrome is infrastructure a route declares into, never markup a page repeats), §A2 (08-navigation-and-shell.md:17, one source of truth for a page's icon and label), and §A3 (08-navigation-and-shell.md:20, active state must be explicit, never inferred by prefix).
Topbar title. A string title renders as a div, not a heading, so the page's own heading stays the only h1. Pass topbar.titleAs ("h1", "h2", ...) when the topbar title is the page heading.
Rail without icons. A nav item with no icon and no color shows its initials (two letters) in the collapsed rail, never a blank pill; the full-width column shows only the label.
Topbar centring. With a search slot the topbar is a three-column grid (minmax(max-content,1fr) auto minmax(0,1fr)): the search sits on the true centre regardless of the title's length, and only a title longer than its column share nudges it right (doc 59 S6). Without a search slot the row is the flex it always was.
When to reach for it#
Reach for AppShell once, at the app root, for a product surface that wants sidebar-plus-topbar navigation and does not need more than that. It is the shell for walmart-mvp (App.tsx:436, 501) and for the forge console (versable-forge-v6, two files import it on its main as of 2026-09-04); the citations below are walmart's, the first consumer.
Do not assume it is the shell for every app on the kit. Speedway built its own two-layer chrome (root.tsx for the topbar and global providers, routes/shell.tsx for sidebar, breadcrumbs, an activity rail, and a peek outlet) before or instead of adopting this component, and imports zero symbols from app-shell (rg AppShell speedway/app returns nothing). AppShell has no activity-rail slot, no peek outlet, and no breadcrumb primitive; a page that needs those reaches for the pattern in method/design-language/08-navigation-and-shell.md §A and §C, not for this component. That doc's own "Portable checklist" section is written for exactly this gap, what a second consumer app should carry over from speedway's shell even though it cannot import speedway's code.
Contract#
Chrome:
-
logorenders in the sidebar header at full width;railLogoreplaces it belowlgor while manually collapsed, where only about 40px of width is available, so give it a mark, not a wordmark. Both are free JSX (walmart'sLogo()/RailLogo(),App.tsx:166-189). -
nav: NavGroup[]. The sidebar's whole content. Keys on a group:label: the kicker text. An empty label renders no kicker and no caret, just the rows, which is the shape for a section's own index row above the groups (the playground'sindexRow()helper,apps/playground/src/app/showcase-registry.ts:624).items: NavItem[]: the rows. Each takeslabel,href, an optionalicon, an optionalactiveoverride, an optionalcolor(an accent dot on the row,sidebar.tsx:140), and a freebadgeslot.count?: a number beside the kicker.collapsible?: groups collapse by default and start expanded (sidebar.tsx:155-157). Passfalseto pin one open.defaultCollapsed?: true: start closed. The shape a long sidebar takes so only the group the reader is in opens.icon?: a mark left of the kicker at kicker size, 13px, or 11px on a sub-group. Use the same icon the group's index card wears (Card.Icon,card.md) so column and cards read as one set.groups?: NavGroup[]: sub-groups under the rows, each with its own smaller kicker and caret, one step in.
Three behaviours you cannot see from the keys. A group holding the active row opens regardless of
defaultCollapsed, so arriving by link always shows where you are. Nesting stops at one level: a sub-group's owngroupsis not rendered. Open state is keyed by path, so two sub-groups both named "Forms" under different sections keep separate carets.navGroupingsits on the shell and the sidebar rather than on a group:flat(default) orrailed, the quiet kicker with rows one step in behind a hairline rail, for a long column (canon 08 §A10).Ruled 2026-08-16 (doc 55 S2, S3): collapsible flipped from off to on by default, so an app upgrading past 0.2.0 sees carets on every labelled group unless it opts out. The group
icon,defaultCollapsedandgroupskeys landed on main after 0.2.0. The playground's "Nested groups" variant (apps/playground/src/app/components/app-shell/examples/sidebar-nested-groups.tsx) shows them andtest/sidebar-nested-groups.test.tsxpins them. -
activeHrefmatches items by exacthrefwhen an item does not set its ownactive. The canon rule is to never rely on this: "active state is explicit, never inferred by prefix" (08-navigation-and-shell.md§A3). The one shipped consumer follows it, computingactiveper item from a typed match (App.tsx:65, 78-100) and never passingactiveHrefat all. -
renderLinkswaps in a router's Link; the kit itself never imports a routing framework, so the default falls back to a plain<a>. Real usage wraps react-router'sLink(App.tsx:158-162,RouterNavLink). -
topbaris four independent slots (title,search,actions,user), each rendering only when passed. The one shipped consumer setstitleonly, to a breadcrumb component (App.tsx:440, 505);search,actions, anduserhave no shipped usage found. Sign-in identity does not live in the topbar'suserslot here; seesidebarFooterbelow. -
The translucent bar, opt-in (
08-navigation-and-shell.md§B): the kitTopbarrenders solidbg-base-100always (topbar.tsx:18) and is not positioned as an overlay.AppShellrenders it as a shrink-0 row besidemain's own independent scroll region (app-shell.tsx:58-75), so nothing ever scrolls beneath it inside the shell. The recipe (surface at/80,backdrop-blur, a border gated on scroll) is a preset for a barfixedorstickyover content, not a prop this component exposes; the shipped case is the playground's own marketing header, notAppShell's topbar. Composing it directly, outside this component, looks like:// apps/playground/src/app/landing.tsx:107-113<headerclassName={`fixed inset-x-0 top-0 z-50 h-16 border-b transition-colors duration-200 ${scrolled? "bg-base-100/80 border-base-300 backdrop-blur": "border-transparent bg-transparent"}`}> -
sidebarTopsits under the logo, hidden while collapsed; the one shipped consumer puts a workspace switcher there (App.tsx:509-516).sidebarFooteris pinned to the sidebar's bottom edge; the one shipped consumer puts an identity dropdown there,SidebarUserMenu, which bundles both the account row and the sign-out action into one menu (App.tsx:192-244, mounted atApp.tsx:517). This diverges from Speedway's own rule that "sign-out lives in the topbar" (08-navigation-and-shell.md§A6): that rule describes Speedway's bespoke shell, not a constraint the kit component enforces.AppShellhas no dedicated sign-out slot, so where sign-out lives is each app's call. -
footerrenders a thin bar under the content area. No shipped usage found.
State:
collapsed/defaultCollapsed/onCollapsedChangeis the standard controlled-or-uncontrolled pair: passcollapsedand handleonCollapsedChangeyourself, or omitcollapsedand let the component hold its own state seeded fromdefaultCollapsed. The shipped consumer controls it, persisting the value tolocalStorageand additionally wiring its own Cmd/Ctrl+B shortcut (App.tsx:382-407). The kit provides the toggle button in the sidebar header, not a keyboard binding.- Below the
lgbreakpoint the sidebar is always icon-rail width regardless ofcollapsed, via a hard-coded Tailwind breakpoint rather than amatchMediahook (sidebar.tsx:221-223,w-16always,lg:w-64only when not collapsed); the manual collapse toggle only changes anything atlgand above. There is no mobile off-canvas or hamburger drawer.hideToggleexists for an app that wants to host its own hamburger elsewhere and drivecollapseditself, but it is a prop of the standaloneSidebaronly (sidebar.tsx:40):AppShellPropsdoes not expose it andAppShellnever forwards it to its internal Sidebar (app-shell.tsx:44-55), so reaching it means composingSidebaryourself. No shipped usage found. - Only one active-item treatment ships:
bg-primary/10 text-primary font-medium(sidebar.tsx:135), matching the canon preset verbatim (08-navigation-and-shell.md§B). sidebarResizable(defaultfalse) drags the sidebar's right edge,lgand up only; the same drag/keyboard engine (internal/use-drag-resize.ts) backsSidePanel's own resize.sidebarWidth(default 256, matching the fixedlg:w-64this replaces),sidebarMinWidth(200),sidebarMaxWidth(400), andsidebarStorageKeyreach the standaloneSidebar's ownresizable/width/minWidth/maxWidth/storageKeyprops (sidebar.tsx:40-58). The separator carriesrole=separator aria-orientation=vertical aria-valuemin/max/now; ArrowLeft/Right and ArrowUp/Down step 16px, Home/End jump to min/max, and a double-click resets to the starting width. Dragging under 120px snaps to the collapsed rail and callsonToggleCollapsed, rather than clamping tominWidth; expanding again restores the last dragged width. WhenstorageKeyis given,{ collapsed, width }persists as one object; a bare boolean already under that key from before this prop existed is read once and folded into the new shape.
Slots and DOM:
- Slot classnames per
AppShellClassNames:className(root),sidebarClassName,topbarClassName,contentClassName. No shipped usage found for any of the four; the one consumer accepts the shell's default look throughout. - The content area publishes its own padding as the
--shell-insetCSS variable (1.5rembelowlg,2rematlg+) so a sticky child can cancel exactly that inset (app-shell.tsx:64-68), the same patternCarduses for--card-inset.
Laws it enforces#
- Every composite declares its narrow behaviour: the sidebar rail gives every item a visible mark, initials when there is no icon.
- Machine values stay exact, human text stays human: the shell title is not a second
h1.
Sanctioned combinations#
| Combination | Produces | Where used | Why |
|---|---|---|---|
Every NavItem.active computed from a typed pathname match, activeHref never passed | Active state that cannot drift from a prefix match | App.tsx:65, 78-100 (buildNav's isActive helper applied to every item) | Canon doc 8 §A3: prefix matching double-stamped active state once already; explicit active is the fix |
A badge built from a small local component that returns null on value == null | A nav count that shows nothing until the real number has loaded, rather than a skeleton or a zero | App.tsx:51-62 (CountPill), wired at App.tsx:88, 90, 94 | Canon doc 8 §B: "An undefined count renders no badge at all" |
The same AppShell instance (same logo, railLogo, nav, renderLink) rendered for the auth-boot state, with only sidebarTop/sidebarFooter swapped to Skeleton and children swapped to a per-route skeleton | Boot chrome that is pixel-identical to loaded chrome, so the two can never drift apart | App.tsx:429-451 | Source comment states it directly: "the REAL AppShell renders (same components as the loaded app)... Only data slots skeleton" |
collapsed controlled from app state, persisted to localStorage, with an app-owned Cmd/Ctrl+B handler calling the same setter onCollapsedChange receives | A sidebar-collapse preference that survives a reload and has a keyboard shortcut the kit does not provide itself | App.tsx:382-407 | The kit's toggle button covers the click path only; an app that wants a shortcut or persistence supplies both itself through the controlled prop pair |
Banned combinations#
Do not infer active from activeHref plus a prefix check done outside the component, for example matching pathname.startsWith(item.href) and never setting item.active. The canon names this exact mistake, already made once and recorded as scar tissue: NavLink prefix matching double-stamped aria-current on a parent from every one of its sub-pages (08-navigation-and-shell.md §A3, citing WorkspaceNav.tsx:85-88). Compute active explicitly per item instead.
Do not render a page's own breadcrumb, sidebar, or topbar markup inside children expecting AppShell to absorb it. The canon's shell principle is that chrome is infrastructure a route declares into, never markup a page repeats (08-navigation-and-shell.md §A1); AppShell's children is the content region only.
Do not mount a second toast stack, modal host, or file-preview host per shell instance. The canon states the asymmetry directly: toasts mount once globally, modals are not centrally mounted (each callsite renders its own locally), and a second file-preview host would double-render (08-navigation-and-shell.md §B, line 41). AppShell has no toast, modal, or preview slot of its own for this reason; see Travels with.
- A nav group kicker lighter than
/65, or a hand-rolled indent on the rows: a lighter ink fails AA at 11px; hierarchy in a long column comes fromnavGrouping="railed"(quiet kicker, rows one step in behind a rail, canon doc 08 §A10), never from restyling the kicker or rows from an app.
Before you adopt this#
Five questions to answer before mounting AppShell or adding anything to its chrome. Each names the failure it catches; a "yes" to the first two means stop.
- Does the shell, a parent layout, or a global provider already render this? A second theme toggle, search box, breadcrumb trail, toast stack, modal host, or preview host is a duplicate, not a feature. The recorded case: a floating bottom-right theme toggle shipped on the playground landing while the navbar already carried one (owner O3, 2026-08-16); the navbar toggle is the only one on every page, and a floating page-level toggle is an anti-pattern.
- Does this app already ship a local implementation of the same thing? Adopt the kit one or keep the local one; two implementations of one chrome element is the defect (
docs/SCRIPTURE.md:274-278records the walmart Select and status-tone cases). - Does this app's kit pin reach the version this component or prop landed in? Read
packages/ui/package.jsonagainst the app's lockfile before writing to a prop; a pin behind the prop fails silently at runtime, not at typecheck (method/app-patterns/12-primitives-and-rules.md:54-60). - Does the component derive its own accessible name and keyboard path, or must the call site supply them?
AppShell's collapse toggle covers the click path only; a shortcut and persistence are the app's to wire throughcollapsed/onCollapsedChange(App.tsx:382-407). - Which canon §A rules bind this surface, and which does the composition break? For the shell: chrome is infrastructure a route declares into, never markup a page repeats (
08-navigation-and-shell.md:15), and active state is explicit, never inferred by prefix (08-navigation-and-shell.md:20). The generated do-nots page (method/DO-NOTS.md) lists every rule that names this component.
Travels with#
Toasts mounts once, as a sibling of AppShell rather than inside any of its slots. The shipped consumer renders <Toasts /> immediately before <AppShell> at the same level (App.tsx:498-499).
Modal / useModal are never centrally mounted through AppShell; each call site renders its own, also as a sibling rather than through a shell slot. The shipped consumer's "New organization" modal renders directly after </AppShell> closes, at the same level as the shell itself (App.tsx:540), not inside any AppShell prop.
Skeleton fills sidebarTop and sidebarFooter during the auth-boot render of the same AppShell instance (App.tsx:441, 444). SkeletonGroup and per-route skeleton modules fill children for the same boot state; see spinner.md's "boot skeleton" pattern, which this is the shell-level half of.
Snippet#
import { AppShell, WorkspaceSwitcher } from "@versable-git/ui";// walmart-mvp/frontend/src/App.tsx:501-524 (trimmed)<AppShell logo={<Logo />} railLogo={<RailLogo />} nav={buildNav(location.pathname, isStaffEmail, navCounts)} topbar={{ title: <Crumb pathname={location.pathname} /> }} sidebarTop={ <WorkspaceSwitcher workspaces={orgs.map((o) => ({ id: o.id, name: o.name, subtitle: o.role }))} value={activeOrg.id} onChange={(id) => selectOrg(id)} onCreate={() => openModal(NEW_ORG_MODAL)} createLabel="New organization" /> } sidebarFooter={ <div className="p-2"> <SidebarUserMenu user={user!} activeOrg={activeOrg} onLogout={logout} /> </div> } renderLink={RouterNavLink} collapsed={sidebarCollapsed} onCollapsedChange={handleCollapsedChange}> <Outlet context={{ user: user!, orgs, activeOrg, /* ... */ }} /></AppShell>