|Kit

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/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="gray">
        <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

Dashboard
Dashboard content

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="gray"><PanelLeft /></ActionIcon>    </AppShell.SidebarTrigger>  </AppShell.Header>  <AppShell.Main><div className="p-4">Page content</div></AppShell.Main></AppShell>

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.

Dashboard
Dashboard content

Code

<AppShell className="h-[28rem] ...">  <AppShell.Sidebar>    <AppShell.SidebarHeader>Logo</AppShell.SidebarHeader>    <AppShell.SidebarBody>      {items.map((item) => <NavItem key={item}>{item}</NavItem>)}    </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="gray" 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):

Dashboard
Dashboard content

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.

Dashboard
Dashboard content

Code

<AppShell className="h-[28rem] ...">  {/* … */}  <AppShell.Aside>    <div className="p-4">On this page…</div>  </AppShell.Aside></AppShell>

Subcomponents

Prop

Type

API

Prop

Type

On this page