App Shell
The page-level application frame — a tinted canvas with a sidebar, content panel, and optional detail aside.
@garn/ui/app-shellOn this page
Default
The framed/inset look: tinted canvas + one floating content panel.
Resizable
Multi-pane composed with resizable drag handles (a pattern, not shell API).
Installation
garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.
npx garn-ui add app-shell- Registry
app-shell- Deps
class-variance-authoritylucide-react- Registry deps
utilsuse-controllable-stateuse-merged-refuse-resize-observeruse-event-listenerbadgebuttoncommanddialogsheet
Import
import { AppShell, AppShellSidebar, AppShellNav, AppShellNavItem, AppShellContent, AppShellHeader, AppShellToolbar, AppShellMain, AppShellFooter, AppShellAside, AppShellTrigger, AppShellCommand, AppShellToolbarSpacer, AppShellSelectionBar, AppShellAsideTrigger } from "@garn/ui/app-shell";Anatomy
The parts this component is built from — compose them to assemble it.
AppShellRequired[data-slot="app-shell"]Root + provider: owns appearance (framed | flush) and the collapse state (useAppShell); renders the skip-link and the canvas.
AppShellSidebar[data-slot="app-shell-sidebar"]Primary navigation region (holds a consumer <nav>). Token-mapped surface appearance — flush (no fill, so the canvas shows through; default) / raised (card, the even-handed choice in a framed shell) / recessed (dimmer, for a nav you want visibly below the canvas) / contrast (dark, scoped) — shared with AppShellAside; collapsible rail/offcanvas on desktop; floats as a drawer Sheet at the compact tier.
AppShellNav[data-slot="app-shell-nav"]A sidebar navigation group — the <nav> landmark that stacks AppShellNavItems. Optional label renders a muted section heading that hides on the rail and names the landmark (aria-labelledby) when no aria-label is given.
AppShellNavItem[data-slot="app-shell-nav-item"]A sidebar nav item — composes Button with rail-collapse baked in: leading icon (stays centered on the rail), trailing count (soft neutral Badge, hidden on the rail), and active (soft fill + aria-current="page" + data-active). variant/tone/asChild pass through, so it also serves a prominent sidebar action (e.g. a brand Compose).
AppShellContent[data-slot="app-shell-content"]The single panel grouping header/toolbar/main/footer. Always carries the raised surface — that is what makes it read as the content panel rather than more canvas; framed adds the float (radius + hairline) on top.
AppShellHeader[data-slot="app-shell-header"]Top bar of the content panel — breadcrumb/title/actions and the AppShellTrigger.
AppShellToolbar[data-slot="app-shell-toolbar"]Secondary strip under the header — saved-view tabs, filters, bulk actions.
AppShellMain[data-slot="app-shell-main"]The scroll region and skip-link target (tabIndex -1).
AppShellFooter[data-slot="app-shell-footer"]Optional footer of the content panel.
AppShellAside[data-slot="app-shell-aside"]Right contextual region — record detail, activity, or an assistant dock; the page's single complementary landmark.
AppShellTrigger[data-slot="app-shell-trigger"]Toggle button for the sidebar (aria-expanded); composes Button.
AppShellCommand[data-slot="app-shell-command"]A ⌘K command palette — composes command in a Dialog with an accessible name; optional / open.
AppShellToolbarSpacer[data-slot="app-shell-toolbar-spacer"]A flex-1 spacer for declarative start/end alignment inside a toolbar or header.
AppShellSelectionBar[data-slot="app-shell-selection-bar"]A contextual bulk-action bar (count + actions + clear) shown when rows are selected. placement="docked" is a full-width strip in place of the toolbar; placement="floating" is a centered Floating Action Bar pill that rises in over the nearest positioned ancestor (give the content region relative). The count is an aria-live region.
AppShellAsideTrigger[data-slot="app-shell-aside-trigger"]Toggle button for the detail Aside (aria-expanded); composes Button. Enables Peek.
Props
The component's public props and their types.
appearance- Description
- No description.
- Type
"flush" | "framed"- Default
flush
collapsible- Description
- No description.
- Type
"icon" | "none" | "offcanvas"- Default
none
placement- Description
- No description.
- Type
"docked" | "floating"- Default
docked
adaptive- Description
- Adapt the layout to the shell's own width — auto-rail the sidebar and float the aside/sidebar as Sheets on narrow. `false` pins the shell `expanded` (static behavior).
- Type
boolean- Default
true
asideOpen- Description
- Controlled detail-aside open state (e.g. for a Peek that opens on row click).
- Type
boolean
defaultAsideOpen- Description
- Initial detail-aside open state (uncontrolled).
- Type
boolean- Default
true
defaultOpen- Description
- Initial sidebar intent (uncontrolled). `undefined` = **auto**: the size class decides (expanded desktop, rail at medium). Seed from the `app-shell:sidebar` cookie server-side for an SSR no-flash restore.
- Type
boolean
defaultSize- Description
- Initial size class before measurement (uncontrolled). Seed from the `app-shell:size` cookie server-side to avoid a first-paint flash.
- Type
"compact" | "expanded" | "medium"
mainId- Description
- Skip-link target; must match the `id` on AppShellMain (shared default).
- Type
string- Default
"app-shell-main"
onAsideOpenChange- Description
- Fires when the detail-aside open state changes.
- Type
(open: boolean) => void
onOpenChange- Description
- Fires when the sidebar open state changes (trigger click, adaptive collapse).
- Type
(open: boolean) => void
onSizeChange- Description
- Fires when the measured (or forced) size class changes.
- Type
(size: "compact" | "medium" | "expanded") => void
open- Description
- Controlled desktop sidebar open state. Always honored (explicit intent).
- Type
boolean
size- Description
- Controlled size class — mostly observed; set it to force a tier (tests, docs).
- Type
"compact" | "expanded" | "medium"
Plus 280 inherited native <div> attributes.
AppShellSidebar
collapsible- Description
- Desktop collapse behavior. `icon` shrinks to a rail; `offcanvas` hides it.
- Type
"icon" | "none" | "offcanvas"
Plus 281 inherited native HTML attributes.
AppShellNav
label- Description
- Optional section heading rendered above the items; hidden on the rail.
- Type
React.ReactNode
Plus 280 inherited native HTML attributes.
AppShellNavItem
active- Description
- Marks the current destination: soft fill + `aria-current="page"`.
- Type
boolean
count- Description
- Trailing count — wrapped in a soft neutral Badge; hidden on the rail.
- Type
React.ReactNode
icon- Description
- Leading icon; stays centered when the sidebar collapses to a rail.
- Type
React.ReactNode
Plus 301 inherited native HTML attributes.
AppShellSelectionBar
clearLabel- Description
- Label for the Clear button.
- Type
string- Default
"Clear"
countRequired- Description
- Number of selected rows — rendered and announced ("N selected", a polite live region).
- Type
number
onClear- Description
- When set, renders a Clear button that calls it — wire to your selection state's `clear`.
- Type
() => void
Plus 281 inherited native HTML attributes.
Styling
Target these data-slots and states, and remap these tokens, to restyle without forking the component.
[data-slot="app-shell"]States
| Selector | State | Description |
|---|---|---|
[data-state=collapsed] | collapsed | Root state when the desktop sidebar is collapsed; sub-parts react via group-data. |
[data-state=expanded] | expanded | Root state when the sidebar is open (default). |
[data-aside=closed] | aside-closed | Root state when the detail Aside is toggled closed; the Aside collapses via group-data. |
[data-size=compact] | size-compact | Adaptive tier < 640px: the sidebar + aside float as overlay Sheets (phone). Sub-parts react via group-data-[size=compact]. |
[data-size=medium] | size-medium | Adaptive tier 640–1024px: the sidebar auto-rails to icons (tablet). |
[data-size=expanded] | size-expanded | Adaptive tier ≥ 1024px: full sidebar + inline aside (desktop; the default look). |
[data-slot=app-shell-selection-bar][data-placement=floating] | selection-bar-floating | The selection bar in its floating placement — a centered pill over the nearest positioned ancestor (vs the default docked strip). |
| Property | Token | Tier |
|---|---|---|
gutter | --garn-space-8 | primitive · air |
regionPadding | --garn-pad-panel | semantic · air |
headerGap | --garn-gap-inline | semantic · air |
radius | --garn-radiusbrandable | semantic |
canvas | --garn-background | semantic |
raised | --garn-card | semantic |
hairline | --garn-border | semantic |
When to use
The top-level frame of an application screen — a dashboard, console, inbox, or records view that needs a persistent sidebar, header, and content area.
Reach for something else when
- A bounded content surface inside a page (use card)
- a marketing/landing page (use plain layout)
- a single overlay task (use dialog/sheet).
Overview
AppShell is the top-level frame of an application screen — a dashboard, console, inbox, or records view that needs a persistent sidebar, header, and content area. AppShell is the root + provider (owns the appearance, collapse state, and the adaptive size class); AppShellSidebar holds primary nav (built from AppShellNav groups + AppShellNavItems — collapsible rail/offcanvas inline, a drawer at the compact tier), and AppShellContent groups AppShellHeader / AppShellToolbar / main / footer. By default it's adaptive: it measures its own width and auto-rails the sidebar at medium and floats the sidebar + aside as Sheets at compact — no consumer media queries. It's the scaffold for a whole app view — for grouping content within a page, use Card.
Guidelines
Use AppShell for the app frame, not for page sections. It's the one persistent chrome around an authenticated product surface; don't nest shells or reach for it to box a widget (that's a Card). Keep one sidebar and one header per shell.
Make navigation survive collapse and mobile. When the sidebar collapses to a rail or moves into a drawer, keep nav items reachable and labelled (icons need accessible names); preserve the user's collapse preference. Provide the skip-link the shell ships so keyboard users can jump to main.
Keep the header and toolbar earning their rows. Put wayfinding (breadcrumb/title) and global actions in AppShellHeader, and view-scoped controls (filters, saved views, bulk actions) in AppShellToolbar — don't crowd both into one bar. Let the content area scroll independently of the chrome.
Best practices
- Use
appearance="framed"for the tinted-canvas inset look; keep at most three surface levels on screen (canvas → raised → overlay). - Separate the regions with one channel at a time. Framed already separates with the gutter, the radius and the hairline, so let the sidebar share the content's raised surface (
appearance="raised") and leave the canvas as the only tinted thing. Flush has no gutter, so the surface step carries it: aflushsidebar shows the canvas through and lands one step below the content panel across the seam. - Put a real <nav> inside AppShellSidebar; place the AppShellTrigger in the header.
- Seed
defaultOpenfrom theapp-shell:sidebarcookie server-side to avoid a collapse flash. - Compose ordinary garn components inside — Card, Table, Breadcrumb — they render identically in framed and flush.
- Don't bake the float into leaf components (cards, tables, tiles) — geometry lives in the shell
appearance. - Don't add a 4th nested surface; the hierarchy collapses.
- Don't reach for shadow to create the float — tonal step + hairline is the point.
- Don't give the sidebar its own landmark role on top of the detail Aside — keep one complementary region.
Troubleshooting
The framed panel doesn't stand out / the canvas looks the same as the panel.
Cause. In light mode the canvas→card step is gentle by design — in a framed shell the gutter, the radius and the hairline carry the separation, so the fill doesn't have to.
Fix. Keep the border-border hairline; don't darken --garn-background and don't tint the sidebar to compensate. Separate with one channel at a time: with a gutter (framed), let nav, content and aside share the raised surface and leave the canvas the only tinted thing; without one (flush), let the surface step do the work. Reach for appearance="recessed" only when you want the nav to sit visibly below the canvas — in a framed shell it stacks a fill on separation the gutter already provides, and the nav reads as sunken.
Skip link focuses nothing / jumps nowhere.
Cause. The skip-link href and AppShellMain id must match.
Fix. Leave both at the default, or set mainId on AppShell and the same id on AppShellMain.
Two complementary landmarks flagged by a11y tooling.
Cause. Making the sidebar an <aside> duplicates the detail Aside's role.
Fix. The sidebar is a layout container; put a <nav> inside it for the navigation landmark.
The floating selection bar sits at the page bottom instead of over the content.
Cause. placement="floating" positions the pill absolutely against the nearest positioned ancestor.
Fix. Give the content/main region relative so the bar floats within it (the demo wraps the showcase table in a relative container).
At tablet width the sidebar's nav icons center but the sidebar stays full-width (a wide half-rail).
Cause. The medium auto-rail relies on the sidebar's own rail width, which only shrinks when collapsible="icon" (or offcanvas). With collapsible="none" the nav items still center on the collapsed state but the panel doesn't narrow.
Fix. Give an adaptive AppShellSidebar collapsible="icon" so it rails cleanly at medium.
The layout flashes from desktop to rail/drawer on first load.
Cause. The size class is measured on the client, so the first server paint has no width yet.
Fix. Seed defaultSize from the app-shell:size cookie server-side (the shell writes it on resize), the same way defaultOpen restores collapse. Or set adaptive={false} to pin the static desktop layout.
A tablet-width uncontrolled sidebar starts railed even though I expected it open.
Cause. Adaptive default-on: with no explicit open/cookie, the size class decides — medium defaults to the rail. This is intended.
Fix. Pass defaultOpen/open to set an explicit intent (honored at medium/expanded), or adaptive={false} for the pre-adaptive always-open behavior.
Clicking a master-list row selects it but the detail Aside never opens, or opens empty.
Cause. The Aside's open state and the selected record are two separate pieces of state; a bare List/Table selection updates neither the shell's asideOpen nor your selected-item state, and the Aside starts collapsed.
Fix. Control asideOpen on AppShell and, in the master list's onSelectionChange (or row onClick), set BOTH the selected record AND asideOpen=true; render an Empty in the Aside while nothing is selected. This is the master-detail recipe — copy the master-detail example rather than re-deriving the wiring.
Accessibility
- Focus
- Skip-link reveals on focus and targets Main (tabIndex -1); focus-visible rings on controls are preserved.
Keyboard
| MetaB | Toggle the sidebar (Ctrl+B on Windows/Linux). |
| MetaK | Open the command palette, when AppShellCommand is used (Ctrl+K). |
| Tab | Skip link (first stop) jumps focus to the main region. |
- AppShell renders a visually-hidden skip-link to the main region (its id defaults to `app-shell-main`; override `mainId` + the Main `id` together).
- Landmark roles: AppShellHeader=<header>, AppShellMain=<main>, AppShellFooter=<footer>, AppShellAside=<aside> (complementary). The sidebar is a layout container — its inner <nav> is the navigation landmark — so the detail Aside stays the page's single complementary region.
- AppShellTrigger exposes aria-expanded; give it (or rely on its default) an accessible name. AppShellAsideTrigger's aria-expanded reflects the effective aside visibility (inline pane or compact overlay).
- AppShellNav is a <nav> landmark: name it with `aria-label`, or pass a `label` heading and it wires `aria-labelledby` to that heading automatically. AppShellNavItem sets aria-current="page" when `active`; an icon-only rail keeps the accessible name from the (visually hidden on rail) label.
- At the compact tier the sidebar AND the detail aside render inside Sheets/dialogs with visually-hidden titles; the aside's overlay uses a separate open state so it doesn't auto-open, while a controlled Peek still drives it.