Skip to content

App Shell

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

Betav0.5.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. 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

SelectorState
[data-state=collapsed]collapsed
[data-slot=app-shell-sidebar][data-rail=true]rail
[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

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

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 (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

error

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

error

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

error

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

error

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

error

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

error

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`)

error

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

error

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

MetaBToggle the sidebar (Ctrl+B on Windows/Linux). Rebindable via `sidebarShortcut`, or `sidebarShortcut={null}` to disable. Ignored while typing in a field.
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 (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.