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.
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.
| Prop | Type | Default | What it does |
|---|---|---|---|
variant | "well" | "inset" | "well" | The chrome of the room and the stage. |
sidebar | ReactNode | The rail, usually a Sidebar. | |
rail | ReactNode | The same as sidebar. sidebar wins when both are set. | |
frame | ReactNode | Chrome beside the stage, such as SidebarTrigger, so a peek cannot cover it. | |
defaultOpen / open / onOpenChange | defaultOpen is true | The rail's open state, as on SidebarProvider. | |
mobile | "sheet" | "push" | "well" | "sheet" | How the rail presents below 768px. |
isMobile | boolean | Follows the viewport | Forces the phone or desktop presentation, for previews and tests. |
skipLink | string | Renders 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 onemainlandmark. Do not nest another<main>inside it. - With
skipLinkthe 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". SidebarTriggeris a button named "Toggle Sidebar", and it reportsaria-expanded.⌘BorCtrl+Btoggles 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
ButtonBorderoutline, 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
SidebarProviderandSidebarInset, when you want the rail and its owninsetvariant 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