Skip to content

App Shell

The page-level application frame — a tinted canvas with a sidebar, content panel, and optional detail aside.

Betav0.4.2added in v0.1.0@garn/ui/app-shell
On this page

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.

terminal
npx garn-ui add app-shell
First time? Set up garn in your project
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

SelectorState
[data-state=collapsed]collapsed
[data-state=expanded]expanded
[data-aside=closed]aside-closed
[data-size=compact]size-compact
[data-size=medium]size-medium
[data-size=expanded]size-expanded
[data-slot=app-shell-selection-bar][data-placement=floating]selection-bar-floating
PropertyTokenTier
gutter--garn-space-8primitive · air
regionPadding--garn-pad-panelsemantic · air
headerGap--garn-gap-inlinesemantic · air
radius--garn-radiusbrandablesemantic
canvas--garn-backgroundsemantic
raised--garn-cardsemantic
hairline--garn-bordersemantic

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

Do
  • 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: a flush sidebar 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 defaultOpen from the app-shell:sidebar cookie server-side to avoid a collapse flash.
  • Compose ordinary garn components inside — Card, Table, Breadcrumb — they render identically in framed and flush.
Don't
  • 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

MetaBToggle the sidebar (Ctrl+B on Windows/Linux).
MetaKOpen the command palette, when AppShellCommand is used (Ctrl+K).
TabSkip link (first stop) jumps focus to the main region.
A1.3.1Info and RelationshipsA2.4.1Bypass BlocksA2.1.1KeyboardAA2.4.7Focus Visible
  • 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.