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

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="neutral"><PanelLeft /></ActionIcon>
    </AppShell.SidebarTrigger>
  </AppShell.Header>
  <AppShell.Main>Page content</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.

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

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>

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.

Sales

Quarterly sales

Prose sits in the column, so a line of text never stretches to whatever width the window happens to be.

RegionOwnerPipelineClosedWin rate
Île-de-FranceAda Lovelace€1.24M€820K64%
Auvergne-Rhône-AlpesGrace Hopper€980K€612K58%
OccitanieKatherine Johnson€745K€498K61%
Hauts-de-FranceRadia Perlman€631K€334K47%

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.Full only works as a direct child of Main. Breaking out of a grid means being in it. For deeper content, make a Full band and use AppShell.Content inside it.
  • AppShell.Content works at any depth, because it re-derives the column's width from the inherited --app-shell-measure / --app-shell-gutter rather than from the grid. It is also the way to get the column inside a fluid Main.

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

v0.18.0
  • asChild is a compile error on the data forms of Accordion, Tabs and Breadcrumb, on the ready-made Notification and on AppShell.Sidebar (their Root parts keep it)

    #294
v0.12.0
  • Main gains a content column with an explicit breakout

    #177
v0.4.0
  • Header and Sidebar chrome take the brand tint, and SidebarHeader drops its bottom border

    #47
v0.3.1
  • Header and the sidebar header draw their bottom borders

    #37
v0.3.0
  • New AppShell: Header, Sidebar, Main and Aside on CSS Grid, responsive to its container, not the viewport

    #19

On this page