# AppShell

Two columns on a desktop, a drawer on a phone.

- Group: Surfaces
- Import: `import { AppShell } from '@misoto22/folio'`
- Page: https://ui.misoto22.com/components/app-shell/
- Related: nav-item, page-header

## Anatomy

- **Frame** (required) — The root: min-h-svh on --paper, one column on a phone and a 15rem sidebar beside a 1fr content column from md up. It is the grid and nothing else — no padding, no measure.
- **Sidebar** (required) — An <aside> named by sidebarLabel, 15rem wide. A static grid column on a desktop; on a phone a fixed drawer that slides in from the edge reading STARTS at, so it comes from the right in a right-to-left document.
- **Brand** — brand, in a 3.5rem row at the top of the sidebar above a hairline — the same height as the topbar, so the two rules meet across the column boundary. Omit it and the nav starts at the top and that line is gone.
- **Nav** (required) — A <nav> named by navLabel, “Primary” by default, holding the sidebar prop. It is the part that scrolls, with scroll-slim, so a list that outgrows the column moves under a brand that stays put.
- **Topbar** (required) — A sticky 3.5rem header at --paper/85 with a backdrop blur and a hairline under it, holding the toggle and then topbar. It is rendered whether or not you pass one.
- **Drawer toggle** — A 44px button, phone-only, swapping Menu for X and carrying aria-expanded plus aria-controls pointing at the sidebar. Its only name is openLabel or closeLabel.
- **Scrim** — A full-screen <button> named by closeLabel, mounted only while the drawer is open and hidden from md up.
- **Content well** (required) — contentAs — a <main> by default — centred at --w-page with --page-pad either side and py-8. The measure and the page padding are the shell’s, so a child that adds its own puts a second measure inside the first.

## Best practices

### Do

- Pass contentAs="div" for a shell rendered inside another page — a documentation preview, a screenshot harness. A document may hold exactly one main, and the second one leaves assistive tech unable to answer “where is the content”.
- Name both landmarks when a page could hold two shells: sidebarLabel and navLabel are the only way one complementary is told from another, and the only way a non-English app gets landmark names its readers can read.
- Translate openLabel and closeLabel with everything else — the toggle holds an icon and no text, so those strings are its entire accessible name on every page of the app.
- Put the whole sidebar in the sidebar prop and let the nav scroll it: build the column yourself with the brand inside it and a long list carries the brand off the top of the screen with it.

### Don’t

- Do not treat the closed drawer as unmounted: below md it is translated off-screen and marked inert, not removed, so everything inside it still renders and still runs its effects — a nav item that measures itself measures a box nobody can see.
- Do not assume no topbar means no bar: the header renders regardless, so a shell with nothing to put up there still costs 3.5rem and a rule across the page.
- Do not wrap the children in your own max-width and page padding — the well already applies both, and the content ends up in the middle of the middle.

## Accessibility

- The drawer closes on Escape as well as on the scrim, so a keyboard user is not stranded inside it.
- The scrim is a <button>, because a div with an onClick is neither reachable nor announced.
- Below md the closed drawer carries inert, so its links are out of the tab order and out of the accessibility tree rather than merely off screen. Above md it never is: there the sidebar is the page’s navigation column.
- Both ways out return focus to the toggle. Focus left inside an inert subtree is focus the browser throws away, and the scrim is worse — it is the focused element and it unmounts.

## AppShell

Two columns on a desktop, one column and a drawer on a phone. The drawer closes on Escape as well as on the scrim, because a drawer that only closes by tapping outside it strands a keyboard user inside a menu they cannot leave. The scrim is a `<button>` for the same reason — a `<div>` with an `onClick` is not reachable by keyboard and not announced as anything. Below `md` the closed drawer is `inert`. Sliding it off-screen is a visual state and nothing more: without that attribute every link in it stays focusable and stays in the accessibility tree, so Tab from the toggle walks into a menu nobody can see. Closing it also returns focus to the toggle, because the element focus was on is the element that just left.

### Props

- `sidebar` (required) — `ReactNode`. Navigation content for the sidebar (e.g. a stack of `NavItem`s).
- `topbar` — `ReactNode`. Optional content for the sticky topbar, laid out after the mobile toggle.
- `brand` — `ReactNode`. Optional brand lockup pinned to the top of the sidebar.
- `contentAs` — `'main' | 'div'`. Which element the content well renders as. `main` is right for the shell of an application, and is the default. A document may contain exactly one `main` landmark, so an AppShell rendered INSIDE another page — a preview on a documentation site, a screenshot harness — must pass `div`, or the page has two and assistive tech can no longer answer "where is the content".
- `sidebarLabel` — `string` default `'Sidebar'`. Names the sidebar landmark. Two `complementary` landmarks with the same name cannot be told apart, and a shell rendered inside another page — a preview, a screenshot harness — makes exactly that pair. It is also the only way a non-English app gets a landmark name its readers can read.
- `navLabel` — `string` default `'Primary'`. Names the navigation landmark inside the sidebar.
- `openLabel` — `string` default `'Open navigation'`. The drawer toggle, closed and open.
- `closeLabel` — `string` default `'Close navigation'`.
- `children` (required) — `ReactNode`.

Also accepts: `HTMLAttributes<HTMLDivElement>`.

## Example — columns and drawer

```tsx
import { AppShell, Card, CardBody, CardTitle, NavItem } from '@misoto22/folio'

<div className="h-96 w-full overflow-hidden rounded-(--radius) border border-(--rule) [&_[class*=min-h-svh]]:min-h-0">
  <AppShell
    // A page may have only one <main>, and this preview sits inside the
    // documentation site's own.
    contentAs="div"
    // The documentation site has a sidebar of its own, and two
    // complementary landmarks with one name cannot be told apart.
    sidebarLabel="Console sidebar"
    navLabel="Console navigation"
    brand={<span className="font-heading text-base">Console</span>}
    sidebar={
      <>
        <NavItem href="#" icon={RiHomeLine} active>Overview</NavItem>
        <NavItem href="#" icon={RiShapesLine}>Components</NavItem>
        <NavItem href="#" icon={RiSettings3Line}>Settings</NavItem>
      </>
    }
    topbar={<span className="mono-meta text-(--ink-3-aa)">production</span>}
  >
    <Card>
      <CardBody>
        <CardTitle>Content well</CardTitle>
      </CardBody>
    </Card>
  </AppShell>
</div>
```

## Example — a scrolling nav

```tsx
import { AppShell, NavItem, Separator, Text } from '@misoto22/folio'

<div className="h-96 w-full overflow-hidden rounded-(--radius) border border-(--rule) [&_[class*=min-h-svh]]:min-h-0">
  <AppShell
    contentAs="div"
    sidebarLabel="Fleet sidebar"
    navLabel="Fleet navigation"
    brand={<span className="font-heading text-base">Fleet</span>}
    sidebar={
      <>
        {SECTIONS.map((section) => (
          <NavItem key={section.label} href="#" icon={section.icon} active={section.label === 'Deploys'}>
            {section.label}
          </NavItem>
        ))}
        <Separator className="my-2" />
        <NavItem href="#">Documentation</NavItem>
      </>
    }
  >
    <Text>
      The sidebar scrolls on its own. The brand row above it does not, and
      neither does the topbar — which is rendered here even though nothing
      was passed to it.
    </Text>
  </AppShell>
</div>
```

## Example — the content well

```tsx
<div className="h-96 w-full overflow-hidden rounded-(--radius) border border-(--rule) [&_[class*=min-h-svh]]:min-h-0">
  <AppShell
    contentAs="div"
    sidebarLabel="Project sidebar"
    navLabel="Project navigation"
    brand={<span className="font-heading text-base">misoto22</span>}
    sidebar={
      <>
        <NavItem href="#" icon={RiDashboardLine}>Overview</NavItem>
        <NavItem href="#" icon={RiGitBranchLine} active>Deploys</NavItem>
        <NavItem href="#" icon={RiStackLine}>Packages</NavItem>
      </>
    }
    topbar={<Badge tone="success">all green</Badge>}
  >
    <Heading level={1} size="heading">
      Deploys
    </Heading>
    <Text className="mt-3">
      Twelve releases in the last thirty days, none rolled back. No wrapper
      around this column — the well is the measure.
    </Text>
    <div className="mt-6 grid gap-4 sm:grid-cols-2">
      <Card>
        <CardHeader>
          <CardTitle as="h2">api</CardTitle>
          <Badge tone="success">live</Badge>
        </CardHeader>
        <CardBody>Deployed from main, 2m 14s ago.</CardBody>
      </Card>
      <Card>
        <CardHeader>
          <CardTitle as="h2">web</CardTitle>
          <Badge tone="warning">building</Badge>
        </CardHeader>
        <CardBody>Started 40 seconds ago on codex/ui-library.</CardBody>
      </Card>
    </div>
  </AppShell>
</div>
```
