AppShell
Header / sidebar / main app frame on CSS Grid named areas. Sidebar scrolls its own content with a pinned header/footer, stays visible on desktop, and becomes an overlay Drawer on mobile.
AppShell is a composable app frame: Header + Sidebar + Main + Aside, laid out with grid-template-areas. Sidebar and Aside each span the full height, on the left and right; Header sits only in the middle column, above Main. There's no Footer and no variant/color axis — like Table/Grid/Flex, it's a structural primitive, not a themed surface.
import { AppShell } from "@42-central/ui-react/app-shell";
<AppShell>
<AppShell.Sidebar>
<AppShell.SidebarHeader>Logo</AppShell.SidebarHeader>
<AppShell.SidebarBody>{/* nav — scrolls */}</AppShell.SidebarBody>
<AppShell.SidebarFooter>{/* user menu */}</AppShell.SidebarFooter>
</AppShell.Sidebar>
<AppShell.Header>
<AppShell.SidebarTrigger asChild>
<ActionIcon variant="subtle" color="neutral">
<PanelLeft />
</ActionIcon>
</AppShell.SidebarTrigger>
</AppShell.Header>
<AppShell.Main>Page content</AppShell.Main>
<AppShell.Aside>{/* e.g. an on-this-page rail */}</AppShell.Aside>
</AppShell>;AppShell fills the viewport (h-dvh) by default — every example below overrides that with a fixed height so it fits the doc page instead. For the real thing, full-page and unbounded:
View full-page example
Basic
Code
<AppShell className="h-[28rem] rounded-lg border border-gray-light-200 dark:border-gray-dark-800">
<AppShell.Sidebar>
<AppShell.SidebarHeader>Logo</AppShell.SidebarHeader>
<AppShell.SidebarBody>Nav</AppShell.SidebarBody>
<AppShell.SidebarFooter>User menu</AppShell.SidebarFooter>
</AppShell.Sidebar>
<AppShell.Header>
<AppShell.SidebarTrigger asChild>
<ActionIcon variant="subtle" color="neutral"><PanelLeft /></ActionIcon>
</AppShell.SidebarTrigger>
</AppShell.Header>
<AppShell.Main>Page content</AppShell.Main>
</AppShell>Sidebar scrolls independently, header/footer stay pinned
SidebarHeader and SidebarFooter are plain flex siblings either side of SidebarBody, which is the only part that scrolls (overflow-y-auto). There's no position: sticky involved — the header/footer simply aren't inside the scroll region.
Code
<AppShell className="h-[28rem] ...">
<AppShell.Sidebar>
<AppShell.SidebarHeader>Logo</AppShell.SidebarHeader>
<AppShell.SidebarBody>
{items.map((item) => (
<NavLink key={item.label} label={item.label} startSlot={item.icon} linkOptions={{ href: item.href }} />
))}
</AppShell.SidebarBody>
<AppShell.SidebarFooter>User menu</AppShell.SidebarFooter>
</AppShell.Sidebar>
{/* … */}
</AppShell>Mobile
Sidebar is always visible on desktop — there's no way to hide it when there's room to show it. Below breakpoint (default 768 px), it stops rendering as a grid column and mounts through the kit's own Drawer instead, full width, opening from the end edge (the right, in LTR), with the same SidebarHeader/SidebarBody/SidebarFooter layout carried over: only the body scrolls, header and footer stay pinned. AppShell.SidebarTrigger renders nothing on desktop (nothing to toggle) and opens/closes the Drawer on mobile.
breakpoint is measured against AppShell's own rendered width (via ResizeObserver), not the browser viewport — so this switches correctly even when AppShell is embedded in a panel, a resizable pane, or anything narrower than the window, not just when it fills the whole page.
At full width there's no backdrop sliver left to tap and no button elsewhere on screen to close it (the trigger that opened it, in Header, is now covered) — swipe-to-dismiss on the Drawer's content works, but a gesture with no visual cue isn't enough on its own, especially over a sidebar full of tappable nav rows. Render a second SidebarTrigger inside SidebarHeader itself, styled as a close icon — it's a no-op on desktop (nothing renders), and on mobile it puts a visible, reachable close control right in the panel that's actually on screen:
<AppShell.SidebarHeader className="justify-between">
<Logo />
<AppShell.SidebarTrigger asChild>
<ActionIcon variant="subtle" color="neutral" aria-label="Close sidebar">
<X />
</ActionIcon>
</AppShell.SidebarTrigger>
</AppShell.SidebarHeader>Resize your browser under 768px against the Basic example above to see it (the frame itself narrows along with the window), or force it regardless of size with an unreachable breakpoint (demo-only trick — pick a real value like the default in production):
Code
<AppShell breakpoint={2000} className="h-[28rem] ...">
{/* forces mobile mode regardless of AppShell's rendered width, for this demo only */}
</AppShell>Aside
The right-hand counterpart to Sidebar — e.g. for an on-this-page/table-of-contents rail. Unlike Sidebar, which spans the full height beside Header, Aside sits below Header (Header spans the rest of the top row above both Main and Aside). It also carries no border or background of its own — a plain content region, styled the same as Main.
Aside is also always mounted: no useContainerQuery, no context, no client state deciding whether it renders at all. It's hidden below a @xl container breakpoint (1280px of AppShell's own rendered width — its grid root carries @container, and Tailwind's native container-query variants read against that) and shown above it purely via static classes (hidden @xl:flex), mirroring how fumadocs' own docs layout keeps its table-of-contents rail always in the DOM rather than conditionally unmounting it — nothing here can ever produce a hydration mismatch, since nothing renders differently based on client-only state. Sidebar can't do the same, because it has two genuinely different structural forms (a plain <aside> vs. a portaled Drawer dialog) — keeping both always mounted would mean duplicating whatever's inside Sidebar into two simultaneous trees. Aside only has one form, so there's nothing to duplicate.
Because it's a container query, not a viewport one, widening your browser only matters here to the extent it widens this bounded preview frame — it's AppShell's own width that's being measured. The full-page example fills the real viewport, so resizing the actual window is the reliable way to see it switch live.
Code
<AppShell className="h-[28rem] ...">
{/* … */}
<AppShell.Aside>
<div className="p-4">On this page…</div>
</AppShell.Aside>
</AppShell>Content column
Main puts its children in a centered content column by default, so a page of prose and forms doesn't stretch to whatever width the window happens to be. Space-hungry blocks — a data table, a carousel, a hero, a full-width background — opt out of it explicitly with AppShell.Full, and AppShell.Content puts arbitrary content back in.
Nothing needs a wrapper: Main supplies the column and its own block padding, so the <div className="p-4"> that used to line every page goes away.
Quarterly sales
Prose sits in the column, so a line of text never stretches to whatever width the window happens to be.
| Region | Owner | Pipeline | Closed | Win rate |
|---|---|---|---|---|
| Île-de-France | Ada Lovelace | €1.24M | €820K | 64% |
| Auvergne-Rhône-Alpes | Grace Hopper | €980K | €612K | 58% |
| Occitanie | Katherine Johnson | €745K | €498K | 61% |
| Hauts-de-France | Radia Perlman | €631K | €334K | 47% |
The band's fill runs edge to edge…
…while AppShell.Content puts its text back on the column's edges — flush with the paragraphs above and below, with no max-width of its own to keep in sync.
Back in the column, with no wrapper to close.
Code
<AppShell.Main>
<Title order={2}>Quarterly sales</Title>
<Text>Prose sits in the column.</Text>
{/* Out to Main's real width — the table gets every pixel */}
<AppShell.Full asChild>
<Table size="sm" striped>
<Table.Content>{/* … */}</Table.Content>
</Table>
</AppShell.Full>
{/* The band's fill runs edge to edge; its text goes back on the column */}
<AppShell.Full asChild>
<section className="border-y bg-(--c-soft) py-5" data-color="purple">
<AppShell.Content>
<Text>Flush with the paragraphs above and below.</Text>
</AppShell.Content>
</section>
</AppShell.Full>
<Text>Back in the column, with no wrapper to close.</Text>
</AppShell.Main>Two things about that frame are staged, and neither is how you'd write it. It drops the Sidebar, because a nav rail would only eat the width the demonstration needs — these previews are bounded to about 720px whatever your window is doing, and every pixel the sidebar takes comes out of Main. And the measure is overridden to 26rem, because the real default is 1280px, so at this size nothing here would be constrained and you'd be looking at a column that never engages. --app-shell-measure is a plain inherited custom property, which is what makes that override a one-liner. The full-page example runs the real default.
How it works, and what that buys
Main is three grid tracks — 1fr, the column, 1fr — rather than a box with inline padding. Spanning all three is what takes a child to the edges, so there are no negative margins, no 100vw (which mismeasures whenever a scrollbar is present), and no JavaScript measuring anything.
Percentages in grid track sizing resolve against Main's own content box, so the column is correct beside a sidebar, inside a resizable pane, or in a bounded frame like the preview above — no media query and no ResizeObserver involved. And because the middle track is min(100% - 2 × gutter, measure), the column narrows into plain padding continuously as space runs out, rather than snapping at a breakpoint.
Two things follow from it being a grid:
AppShell.Fullonly works as a direct child ofMain. Breaking out of a grid means being in it. For deeper content, make aFullband and useAppShell.Contentinside it.AppShell.Contentworks at any depth, because it re-derives the column's width from the inherited--app-shell-measure/--app-shell-gutterrather than from the grid. It is also the way to get the column inside afluidMain.
AppShell.Full sets no class of its own — it marks itself data-full, and Main's own rule places it. With asChild that marker merges onto your component, so a breakout costs nothing in the DOM.
Sizing and opting out
size picks the measure from the kit's --width-* tokens: sm 48rem, md 64rem, lg 80rem (the default), xl 90rem. For a one-off, set --app-shell-measure or --app-shell-gutter through className — the gutter also drives Main's block padding.
fluid drops the column entirely, restoring an edge-to-edge Main. Use it when Main is one full-canvas component rather than a document — a Schedule, or a DataTable sized to the viewport:
<AppShell.Main fluid>
<Schedule data={events} />
</AppShell.Main>A single full-height child needs this rather than AppShell.Full: Main stacks its rows with align-content: start (otherwise grid hands leftover vertical space to every row and spaces a short page out down the viewport), and a child cannot resolve h-full against an auto row. fluid removes the grid, so h-full resolves against Main itself again.
Vertical rhythm is left alone — Main sets no row gap, so add className="gap-6" if you want one.
Subcomponents
Prop
Type
API
AppShell
Prop
Type
AppShell.Main
Prop
Type
Changelog
- #294
asChildis a compile error on the data forms ofAccordion,TabsandBreadcrumb, on the ready-madeNotificationand onAppShell.Sidebar(theirRootparts keep it)
- #177
Main gains a content column with an explicit breakout
- #47
Header and Sidebar chrome take the brand tint, and SidebarHeader drops its bottom border
- #37
Header and the sidebar header draw their bottom borders
- #19
New
AppShell: Header, Sidebar, Main and Aside on CSS Grid, responsive to its container, not the viewport
Collapse
Reveal or hide content with a pure-CSS height animation — a single-cell grid whose row track animates between 0fr and 1fr, so auto-height content expands and collapses cleanly both ways.
AmbientBackground
A decorative, brand-tinted page background — a soft radial glow plus a fine noise texture — so a flat page fill reads as a considered surface instead of a plain color.