# AppShell (/docs/app-shell)





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

```tsx
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:

<Button endSlot="<ArrowUpRight />">
  <Link href="/examples/app-shell" target="_blank" rel="noopener noreferrer">
    View full-page example
  </Link>
</Button>

## Basic [#basic]

<Preview align="block" code="&#x22;<AppShell className=\&#x22;h-[28rem] rounded-lg border border-gray-light-200 dark:border-gray-dark-800\&#x22;>\n  <AppShell.Sidebar>\n    <AppShell.SidebarHeader>Logo</AppShell.SidebarHeader>\n    <AppShell.SidebarBody>Nav</AppShell.SidebarBody>\n    <AppShell.SidebarFooter>User menu</AppShell.SidebarFooter>\n  </AppShell.Sidebar>\n  <AppShell.Header>\n    <AppShell.SidebarTrigger asChild>\n      <ActionIcon variant=\&#x22;subtle\&#x22; color=\&#x22;neutral\&#x22;><PanelLeft /></ActionIcon>\n    </AppShell.SidebarTrigger>\n  </AppShell.Header>\n  <AppShell.Main>Page content</AppShell.Main>\n</AppShell>&#x22;">
  <AppShellExample />
</Preview>

## Sidebar scrolls independently, header/footer stay pinned [#sidebar-scrolls-independently-headerfooter-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.

<Preview align="block" code="&#x22;<AppShell className=\&#x22;h-[28rem] ...\&#x22;>\n  <AppShell.Sidebar>\n    <AppShell.SidebarHeader>Logo</AppShell.SidebarHeader>\n    <AppShell.SidebarBody>\n      {items.map((item) => (\n        <NavLink key={item.label} label={item.label} startSlot={item.icon} linkOptions={{ href: item.href }} />\n      ))}\n    </AppShell.SidebarBody>\n    <AppShell.SidebarFooter>User menu</AppShell.SidebarFooter>\n  </AppShell.Sidebar>\n  {/* … */}\n</AppShell>&#x22;">
  <AppShellExample navItemCount="21" />
</Preview>

## Mobile [#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&#x60; 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:

```tsx
<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](#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):

<Preview align="block" code="&#x22;<AppShell breakpoint={2000} className=\&#x22;h-[28rem] ...\&#x22;>\n  {/* forces mobile mode regardless of AppShell's rendered width, for this demo only */}\n</AppShell>&#x22;">
  <AppShellExample breakpoint="2000" />
</Preview>

## Aside [#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](/examples/app-shell) fills the real viewport, so resizing the actual window is the reliable way to see it switch live.

<Preview align="block" code="&#x22;<AppShell className=\&#x22;h-[28rem] ...\&#x22;>\n  {/* … */}\n  <AppShell.Aside>\n    <div className=\&#x22;p-4\&#x22;>On this page…</div>\n  </AppShell.Aside>\n</AppShell>&#x22;">
  <AppShellExample
    aside="
    <div className=&#x22;p-4 text-sm text-gray-light-500 dark:text-gray-dark-400&#x22;>
      On this page…
    </div>
  "
  />
</Preview>

## Content column [#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.

<Preview align="block" code="&#x22;<AppShell.Main>\n  <Title order={2}>Quarterly sales</Title>\n  <Text>Prose sits in the column.</Text>\n\n  {/* Out to Main's real width — the table gets every pixel */}\n  <AppShell.Full asChild>\n    <Table size=\&#x22;sm\&#x22; striped>\n      <Table.Content>{/* … */}</Table.Content>\n    </Table>\n  </AppShell.Full>\n\n  {/* The band's fill runs edge to edge; its text goes back on the column */}\n  <AppShell.Full asChild>\n    <section className=\&#x22;border-y bg-(--c-soft) py-5\&#x22; data-color=\&#x22;purple\&#x22;>\n      <AppShell.Content>\n        <Text>Flush with the paragraphs above and below.</Text>\n      </AppShell.Content>\n    </section>\n  </AppShell.Full>\n\n  <Text>Back in the column, with no wrapper to close.</Text>\n</AppShell.Main>&#x22;">
  <AppShellColumnExample />
</Preview>

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](/examples/app-shell) runs the real default.

### How it works, and what that buys [#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 [#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:

```tsx
<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 [#subcomponents]

<TypeTable
  type="{
  AppShell: {
    type: &#x22;component&#x22;,
    description:
      &#x22;Root — sets up the grid and owns mobile-open state. Takes `breakpoint`.&#x22;,
  },
  &#x22;AppShell.Sidebar&#x22;: {
    type: &#x22;component&#x22;,
    description:
      &#x22;The left region, full height, always visible on desktop. Below `breakpoint`, renders through Drawer instead of the grid column. Takes `size` (width, xs–xl).&#x22;,
  },
  &#x22;AppShell.SidebarHeader&#x22;: {
    type: &#x22;component&#x22;,
    description: &#x22;Non-scrolling flex child above SidebarBody.&#x22;,
  },
  &#x22;AppShell.SidebarBody&#x22;: {
    type: &#x22;component&#x22;,
    description: &#x22;The scroll region — the only part of Sidebar that scrolls.&#x22;,
  },
  &#x22;AppShell.SidebarFooter&#x22;: {
    type: &#x22;component&#x22;,
    description: &#x22;Non-scrolling flex child below SidebarBody.&#x22;,
  },
  &#x22;AppShell.SidebarTrigger&#x22;: {
    type: &#x22;component&#x22;,
    description:
      &#x22;Opens/closes the Drawer on mobile. Renders nothing on desktop — Sidebar is always visible there. Place a second one inside SidebarHeader, styled as a close icon, so the mobile Drawer has a visible way to close. asChild-able.&#x22;,
  },
  &#x22;AppShell.Header&#x22;: {
    type: &#x22;component&#x22;,
    description: &#x22;Sits beside Sidebar and Aside, above Main.&#x22;,
  },
  &#x22;AppShell.Main&#x22;: {
    type: &#x22;component&#x22;,
    description:
      &#x22;The page content region; scrolls independently. Lays its children out in a centered content column by default. Takes `size` (measure, sm–xl) and `fluid`.&#x22;,
  },
  &#x22;AppShell.Full&#x22;: {
    type: &#x22;component&#x22;,
    description:
      &#x22;Opts one direct child of Main out of the content column, spanning Main's full width — for data tables, carousels, heroes and full-width backgrounds. asChild-able, and adds no element of its own when used that way.&#x22;,
  },
  &#x22;AppShell.Content&#x22;: {
    type: &#x22;component&#x22;,
    description:
      &#x22;Puts content back into Main's column, at any depth — for a full-width band whose text should still line up with the rest of the page.&#x22;,
  },
  &#x22;AppShell.Aside&#x22;: {
    type: &#x22;component&#x22;,
    description:
      &#x22;The right region, full height. Always mounted — hidden below xl, shown above it, purely via CSS (no useMediaQuery/context). Takes `size` (width, xs–xl).&#x22;,
  },
}"
/>

## API [#api]

### `AppShell` [#appshell]

<TypeTable
  type="{
  breakpoint: {
    type: &#x22;number&#x22;,
    default: &#x22;768&#x22;,
    description:
      &#x22;Viewport width (px) below which Sidebar renders as an overlay Drawer instead of a grid column.&#x22;,
  },
  className: {
    type: &#x22;string&#x22;,
    description:
      &#x22;Styles the grid root. Overrides the default h-dvh (e.g. to bound the shell to a fixed height instead of the viewport).&#x22;,
  },
  asChild: {
    type: &#x22;boolean&#x22;,
    default: &#x22;false&#x22;,
    description: &#x22;Render children as the root element (Ark UI polymorphism).&#x22;,
  },
}"
/>

### `AppShell.Main` [#appshellmain]

<TypeTable
  type="{
  size: {
    type: '&#x22;sm&#x22; | &#x22;md&#x22; | &#x22;lg&#x22; | &#x22;xl&#x22;',
    default: '&#x22;lg&#x22;',
    description:
      &#x22;Width of the content column, from the kit's --width-* tokens: sm 48rem, md 64rem, lg 80rem, xl 90rem.&#x22;,
  },
  fluid: {
    type: &#x22;boolean&#x22;,
    default: &#x22;false&#x22;,
    description:
      &#x22;Drop the content column entirely — children fill Main edge to edge. For pages whose Main is one full-canvas component rather than a document.&#x22;,
  },
  className: {
    type: &#x22;string&#x22;,
    description:
      &#x22;Styles the region. Also where you override --app-shell-measure or --app-shell-gutter for a one-off, or add a row gap.&#x22;,
  },
  asChild: {
    type: &#x22;boolean&#x22;,
    default: &#x22;false&#x22;,
    description: &#x22;Render children as the root element (Ark UI polymorphism).&#x22;,
  },
}"
/>
