Gesso
Layout

App shell

The frame around an app's pages, with a rail on one side and a stage for the page, in a sunken well or a flat inset.

App shell is the frame around a page: a room, a rail beside it, and a stage that holds the content. It sits above Sidebar, which keeps the rail itself, so collapse, ⌘B, the sidebar, floating, and inset panel variants, and everything the rail does on a phone stay where they are. App shell only decides how the room and the stage look, with well or inset.

Well: sunken tray, raised stage.

Skip to content
Ramson · Inbox⌘B

Raised stage

Deck — open the stack. Collapse the rail to the mark.

Usage

import { AppShell } from '@/components/ui/app-shell';
import {
  Sidebar,
  SidebarContent,
  SidebarHeader,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarTrigger,
} from '@/components/ui/sidebar';
import { TenantPicker } from '@/components/ui/tenant-picker';

<AppShell
  variant="well"
  defaultOpen
  sidebar={
    <Sidebar collapsible="icon">
      <SidebarHeader>
        <TenantPicker variant="deck" tenants={tenants} value={tenant} onValueChange={setTenant} />
      </SidebarHeader>
      <SidebarContent>
        <SidebarMenu>
          <SidebarMenuItem>
            <SidebarMenuButton isActive tooltip="Inbox">
              Inbox
            </SidebarMenuButton>
          </SidebarMenuItem>
        </SidebarMenu>
      </SidebarContent>
    </Sidebar>
  }
>
  <SidebarTrigger />
  {children}
</AppShell>

Pass your navigation through sidebar (or rail, which is the same prop) and the page through children. A Tenant picker in the rail header keeps the mark reachable when the rail collapses to its icons.

Well or inset

well is the app-in-a-tray look. The room is bg-surface-sunken with 6px of padding, the rail sits in it with no borders, and the stage is a rounded slab (bg-surface-raised, rounded-xl, shadow-sm). The stage is white on a gray tray in light, and in dark the tray is a half step lighter than the stage, so the rail always reads apart from the page. It is the default, and the room is h-svh with overflow hidden, so the stage has to scroll its own content.

inset is the flat product shell. The room is bg-surface and the stage is bg-surface-raised, with no tray, no radius, and no shadow, and the room is min-h-svh, so the page scrolls as an ordinary column.

<AppShell variant="inset" sidebar={rail}>
  {children}
</AppShell>

Only the room and the stage change with variant. In well, pass a default Sidebar and not variant="inset" or "floating", because the shell strips the rail's strokes and fill to sit it in the tray. In inset, the stage stays flat whichever Sidebar variant you pass. The stage eases between treatments over --duration-base, and the rail sits inside the shell and not fixed to the viewport, so the shell can live in a box smaller than the window, as the demo does.

Use the shell's variant to tint the room. SidebarProvider already paints has-data-[variant=inset]:bg-surface, and the stage sets data-variant so the matching has-data-[variant=well]:bg-surface-sunken fires, so there is no need to set Sidebar to inset just to color the room.

The frame and the phone

frame renders beside the stage, on a data-slot="app-shell-frame" element that is relative. Put SidebarTrigger there, with absolute and z-40, when something such as a peek panel covers the stage, so the trigger stays clickable. With nothing covering the stage, the trigger can go in children.

<AppShell
  variant="well"
  sidebar={<Sidebar collapsible="icon">{nav}</Sidebar>}
  frame={<SidebarTrigger className="absolute top-3 left-3 z-40" />}
>
  {children}
</AppShell>

Below 768px the rail leaves the page. By default it opens as a Sheet over the stage, and mobile="push" or mobile="well" turns it into a drawer the stage shoulders over. These are Sidebar's modes and they pass through AppShell to the provider, and the same trigger opens any of them. See Sidebar for how each one moves. The stage is SidebarInset, so it is the part that slides, and a peek host, a command palette, and dialogs belong in your app and not in the shell. In push and well the stage eases as it slides over, and in the well as it scales back, on the same --duration-base as its other changes.

Props

AppShell takes the props of SidebarProvider and every <div> attribute, plus these. className and style go on the outer room.

PropTypeDefaultWhat it does
variant"well" | "inset""well"The chrome of the room and the stage.
sidebarReactNodeThe rail, usually a Sidebar.
railReactNodeThe same as sidebar. sidebar wins when both are set.
frameReactNodeChrome beside the stage, such as SidebarTrigger, so a peek cannot cover it.
defaultOpen / open / onOpenChangedefaultOpen is trueThe rail's open state, as on SidebarProvider.
mobile"sheet" | "push" | "well""sheet"How the rail presents below 768px.
isMobilebooleanFollows the viewportForces the phone or desktop presentation, for previews and tests.
skipLinkstringRenders a link with this text, like "Skip to content", first in the shell. It shows only while it has focus and moves focus to the stage.

AppShellStage is exported for the case where you compose SidebarProvider yourself. It takes variant and the props of SidebarInset.

Accessibility

  • The stage is a <main>, so the shell supplies the page's one main landmark. Do not nest another <main> inside it.
  • With skipLink the shell's first tab stop is a link that jumps past the rail to the stage, which takes focus. It is visually hidden until it has focus. The rail is a <nav> named "Sidebar".
  • SidebarTrigger is a button named "Toggle Sidebar", and it reports aria-expanded. ⌘B or Ctrl+B toggles the rail from anywhere except an input, a textarea, a select, or an editable field.
  • On a phone the rail is a named dialog. The Sheet traps focus and closes on Escape. The push drawer is a modal dialog named "Sidebar" that makes the page inert while it is open, and its scrim is a button named "Close sidebar". Under mobile="well" the page is not marked modal.
  • In forced colors the stage keeps a ButtonBorder outline, so the slab stays visible when its shadow and fill are dropped.
  • Under reduced motion the stage, the rail, and the push drawer change without a transition.

When to use something else

  • Sidebar alone, with SidebarProvider and SidebarInset, when you want the rail and its own inset variant and no tray. Reach for App shell when you want the sunken well or a one-prop switch between the two.
  • Page header for the title bar at the top of the stage, and Section for the blocks below it. The Issues example builds a surface from a rail and a page header.
  • Tabs when the navigation is a few views of one page and not the app's destinations.

Install

shadcn add @gesso/app-shell

On this page