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. docs/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 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, the kit's one real consumer today (App.tsx:436, 501).
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 docs/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[]. EachNavGroupis alabel, an optionalcount, an optionalcollapsible, anditems: NavItem[]. Groups collapse by default: the kicker carries a caret, a click hides the items, and every group starts expanded (sidebar.tsx:110-135). Passcollapsible: falseto pin a group open. A group with an emptylabelrenders no kicker at all, just its items, and never collapses; that is the shape for a section's own index row above the groups (the playground's "All components" and "All patterns" rows,apps/playground/src/app/showcase-registry.ts:466). Owner ruling 2026-08-16 (doc 55 S2, S3): the default flipped from off to on, so an app upgrading past 0.2.0 sees carets on every labelled group unless it opts out. EachNavItemtakeslabel,href, an optionalIcon, an optionalactiveoverride, an optionalcolor(accent dot on the item row,sidebar.tsx:154-157), and a freebadgeslot.navGroupingon the shell and the sidebar:flat(default, unchanged) orrailed, the quiet kicker with rows one step in behind a hairline rail, for a long column (canon 08 §A10; the playground's docs and patterns columns). Three more keys on a group (on main after 0.2.0):Icondraws a mark left of the kicker at kicker size (13px, 11px on a sub-group), meant to be the same icon the group's index card wears (Card.Icon,card.md) so the column and the cards read as one set;defaultCollapsed: truestarts the group closed, the shape a long sidebar takes so only the group the reader is in opens (the playground's docs column); a group holding the active row renders open regardless, so arriving by link always shows where you are; andgroups: NavGroup[]nests sub-groups under the group's rows, each with its own smaller kicker and caret, one step in. Nesting stops at one level: a sub-group's owngroupsis not rendered. Open/closed state is keyed by path, so two sub-groups named "Forms" under different sections keep separate carets. The playground's "Nested groups" variant (apps/playground/src/app/components/app-shell/examples/sidebar-nested-groups.tsx) shows both;test/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:56-63), 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:70-71, 78); 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:33):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:150), matching the canon preset verbatim (08-navigation-and-shell.md§B).
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.
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:257-268records 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 (docs/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 (docs/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#
// 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>