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).
Related components
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. It reflects data-rail only when it actually rails (collapsible="icon" × collapsed), which is what the nav parts key their collapse on. When fully collapsed offcanvas it is inert. At the compact tier it floats as a drawer Sheet — named by label / description (i18n), reflecting data-appearance and carrying its surface fill (a contrast/recessed nav keeps its treatment on mobile).
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. Named via aria-label (with description for its compact overlay). When toggled closed it is inert (out of the tab order); at the compact tier it floats as an overlay Sheet.
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 over the nearest positioned ancestor (give the content region relative). The region carries a stable name (regionLabel); the count sits in a polite status live region (countLabel) so a changing count doesn't re-announce the landmark.
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). Always honored — a controlled aside opts out of the tier auto-close.
- Type
boolean
defaultAsideOpen- Description
- Initial detail-aside open state at the **expanded** tier (uncontrolled). The medium tier auto-closes an untouched aside regardless (so a tablet-width shell isn't split three ways); a user toggle or a controlled `asideOpen` overrides, and the compact tier floats it as an overlay.
- 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 user toggles the sidebar (trigger, ⌘B). Tier-driven reconciliation is derived, and does not fire this.
- Type
(open: boolean) => void
onSizeChange- Description
- Fires when the measured (or forced) size class changes.
- Type
(size: "compact" | "medium" | "expanded") => void
open- Description
- Controlled sidebar intent. Honored at the medium/expanded tiers; the compact tier always overlays a drawer regardless of this value.
- Type
boolean
persistKey- Description
- Namespaces the persisted collapse/size cookies (multiple shells on a page), or `null` to persist nothing. `undefined` keeps the shared default keys.
- Type
string
sidebarShortcut- Description
- Keyboard shortcut key that toggles the sidebar (with ⌘/Ctrl). `null` disables the shortcut entirely (e.g. a second shell on the page).
- Type
string- Default
"b"
size- Description
- Controlled size class — mostly observed; set it to force a tier (tests, docs).
- Type
"compact" | "expanded" | "medium"
skipLabel- Description
- Skip-link text — the first tab stop that jumps to the main region.
- Type
string- Default
"Skip to content"
Plus 280 inherited native <div> attributes.
AppShellSidebar
collapsible- Description
- Desktop collapse behavior. `icon` shrinks to a rail; `offcanvas` hides it.
- Type
"icon" | "none" | "offcanvas"
description- Description
- Accessible description for the compact navigation drawer.
- Type
string- Default
"Application sidebar"
label- Description
- Accessible name for the compact navigation drawer.
- Type
string- Default
"Navigation"
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.
AppShellAside
description- Description
- Accessible description for the compact detail overlay (the accessible name comes from `aria-label`).
- Type
string- Default
"Details panel"
Plus 281 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 via a polite `status` live region.
- Type
number
countLabel- Description
- Formats the visible + announced count text.
- Type
(count: number) => string- Default
(n) => `${n} selected`
onClear- Description
- When set, renders a Clear button that calls it — wire to your selection state's `clear`.
- Type
() => void
regionLabel- Description
- Stable accessible name for the region landmark — kept constant so the count changing doesn't re-announce the landmark itself.
- Type
string- Default
"Selection"
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-slot=app-shell-sidebar][data-rail=true] | rail | Set on the sidebar only when it truly rails (`collapsible="icon"` × collapsed); the nav parts key their icon-only collapse on this, so a non-railing sidebar keeps full-width labels at the medium tier. |
[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 and an uncontrolled detail aside auto-closes (tablet) — so the shell isn't split three ways. |
[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 |
Private instance vars (never externalize): --app-shell-sidebar-w--app-shell-rail-w--app-shell-aside-w
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 (medium) width the sidebar stays full-width with full labels — I expected it to shrink to an icon rail.
Cause. A sidebar only rails when it can: the icon-rail collapse (and the sidebar's data-rail that the nav items key on) is gated on collapsible="icon". The default collapsible="none" never rails, so at medium it just stays a full sidebar (correct — no stranded half-rail).
Fix. Give an adaptive AppShellSidebar collapsible="icon" so it rails cleanly at medium; leave it none if you want the full sidebar to persist.
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.
The detail aside disappears at tablet (medium) width even though defaultAsideOpen is true.
Cause. Adaptive default: an uncontrolled aside auto-closes at the medium tier so the shell isn't split three ways — and the medium tier closes it regardless of defaultAsideOpen, which only sets the expanded default.
Fix. Control asideOpen (it opts out of the auto-close and you own the tier logic), or expect the user to open it with AppShellAsideTrigger at medium (the toggle is honored across tiers). For a fixed three-pane demo, pin size="expanded".
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.
Accessibility requirements
The shell renders a skip-link as the first tab stop that moves focus to the main region (its href matches AppShellMain's id).
when always
The detail Aside is the page's single `complementary` landmark; the sidebar is a layout container (put a <nav> inside it), not a second complementary region.
when a shell has both a sidebar and a detail aside
The fully-collapsed pane is `inert` — its content is removed from the tab order and the accessibility tree, so keyboard focus is never stranded in a zero-width region.
when the sidebar is offcanvas-collapsed, or the inline aside is toggled closed
Each toggle has an accessible name (a sensible default, overridable via `aria-label`) and reflects the region's open state through `aria-expanded`.
when AppShellTrigger / AppShellAsideTrigger are used
The <nav> landmark is named — by `aria-label`, or, when a `label` heading is given and no aria-label is set, by `aria-labelledby` pointing at that heading.
when AppShellNav is used
The active item sets `aria-current="page"`; on the icon rail the (visually hidden) label still supplies the accessible name.
when an AppShellNavItem is the current destination (`active`)
The count is exposed via a polite `role="status"` live region, and the region landmark keeps a stable name (`regionLabel`) so the changing count doesn't re-announce it.
when AppShellSelectionBar is shown
The overlay carries an accessible name (and description) via a visually-hidden title — set with `label`/`description` on the sidebar and `aria-label`/`description` on the aside.
when the sidebar or aside renders as a Sheet at the compact tier
Keyboard
| MetaB | Toggle the sidebar (Ctrl+B on Windows/Linux). Rebindable via `sidebarShortcut`, or `sidebarShortcut={null}` to disable. Ignored while typing in a field. |
| 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 (set via `label`/`description` on the sidebar, `aria-label`/`description` on the aside), each carrying its `appearance` surface (reflected as `data-appearance`); the aside's overlay uses a separate open state so it doesn't auto-open, while a controlled Peek still drives it.
- A fully-collapsed pane is inert: an offcanvas-collapsed sidebar and a closed inline aside carry the `inert` attribute, so their content leaves the tab order and the accessibility tree (no focus stranded in a zero-width region). A rail (`collapsible="icon"`) stays interactive — its icons remain.
- AppShellSelectionBar names its region with a stable `regionLabel` (default "Selection") and reports the count through a polite `role="status"` live region (`countLabel`), so the changing count is announced without re-labelling the landmark.
- Status
- Beta
- Version
v0.5.0- Introduced
v0.1.0
garn doesn't yet record a per-release version history for individual components. This reflects the component's current lifecycle state; a full per-release history is coming.