Agent docs

AppShell

Whole-app chrome: nav sidebar, topbar slots, and the padded page frame.

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:

  • logo renders in the sidebar header at full width; railLogo replaces it below lg or while manually collapsed, where only about 40px of width is available, so give it a mark, not a wordmark. Both are free JSX (walmart's Logo()/RailLogo(), App.tsx:166-189).

  • nav: NavGroup[]. Each NavGroup is a label, an optional count, an optional collapsible, and items: NavItem[]. Groups collapse by default: the kicker carries a caret, a click hides the items, and every group starts expanded (sidebar.tsx:110-135). Pass collapsible: false to pin a group open. A group with an empty label renders 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. Each NavItem takes label, href, an optional Icon, an optional active override, an optional color (accent dot on the item row, sidebar.tsx:154-157), and a free badge slot. navGrouping on the shell and the sidebar: flat (default, unchanged) or railed, 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): Icon draws 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: true starts 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; and groups: 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 own groups is 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.tsx pins them.

  • activeHref matches items by exact href when an item does not set its own active. 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, computing active per item from a typed match (App.tsx:65, 78-100) and never passing activeHref at all.

  • renderLink swaps 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's Link (App.tsx:158-162, RouterNavLink).

  • topbar is four independent slots (title, search, actions, user), each rendering only when passed. The one shipped consumer sets title only, to a breadcrumb component (App.tsx:440, 505); search, actions, and user have no shipped usage found. Sign-in identity does not live in the topbar's user slot here; see sidebarFooter below.

  • The translucent bar, opt-in (08-navigation-and-shell.md §B): the kit Topbar renders solid bg-base-100 always (topbar.tsx:18) and is not positioned as an overlay. AppShell renders it as a shrink-0 row beside main'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 bar fixed or sticky over content, not a prop this component exposes; the shipped case is the playground's own marketing header, not AppShell's topbar. Composing it directly, outside this component, looks like:

    // apps/playground/src/app/landing.tsx:107-113
    <header
    className={`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"
    }`}
    >
  • sidebarTop sits under the logo, hidden while collapsed; the one shipped consumer puts a workspace switcher there (App.tsx:509-516). sidebarFooter is 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 at App.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. AppShell has no dedicated sign-out slot, so where sign-out lives is each app's call.

  • footer renders a thin bar under the content area. No shipped usage found.

State:

  • collapsed / defaultCollapsed / onCollapsedChange is the standard controlled-or-uncontrolled pair: pass collapsed and handle onCollapsedChange yourself, or omit collapsed and let the component hold its own state seeded from defaultCollapsed. The shipped consumer controls it, persisting the value to localStorage and 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 lg breakpoint the sidebar is always icon-rail width regardless of collapsed, via a hard-coded Tailwind breakpoint rather than a matchMedia hook (sidebar.tsx:70-71, 78); the manual collapse toggle only changes anything at lg and above. There is no mobile off-canvas or hamburger drawer. hideToggle exists for an app that wants to host its own hamburger elsewhere and drive collapsed itself, but it is a prop of the standalone Sidebar only (sidebar.tsx:33): AppShellProps does not expose it and AppShell never forwards it to its internal Sidebar (app-shell.tsx:44-55), so reaching it means composing Sidebar yourself. 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-inset CSS variable (1.5rem below lg, 2rem at lg+) so a sticky child can cancel exactly that inset (app-shell.tsx:64-68), the same pattern Card uses for --card-inset.

Sanctioned combinations#

CombinationProducesWhere usedWhy
Every NavItem.active computed from a typed pathname match, activeHref never passedActive state that cannot drift from a prefix matchApp.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 == nullA nav count that shows nothing until the real number has loaded, rather than a skeleton or a zeroApp.tsx:51-62 (CountPill), wired at App.tsx:88, 90, 94Canon 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 skeletonBoot chrome that is pixel-identical to loaded chrome, so the two can never drift apartApp.tsx:429-451Source 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 receivesA sidebar-collapse preference that survives a reload and has a keyboard shortcut the kit does not provide itselfApp.tsx:382-407The 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 from navGrouping="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.

  1. 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.
  2. 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-268 records the walmart Select and status-tone cases).
  3. Does this app's kit pin reach the version this component or prop landed in? Read packages/ui/package.json against 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).
  4. 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 through collapsed / onCollapsedChange (App.tsx:382-407).
  5. 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>
@versable-git/ui · reference, canon, and method, read in place