App shell

A recipe, not a package namespace. An app shell is the frame around a product's pages: a header across the top, a Nav sidebar in a Splitter beside the main content, and, below a width where both don't fit, the sidebar in a Drawer that a button in the header opens. The pages, their links and the breakpoint are your product's. There is no AppShell.* export.

On this page

Drag the handle, or hide the navigation from the header's first button. On a narrow screen the same button opens the navigation in a drawer.

Home

The navigation is in a drawer: open it from the button in the header.

Layout

The header comes first, with a skip link at its start, then the splitter, with the sidebar's Nav.Root in the first panel and main in the second. Give main the skip link's target id and tabIndex={-1}, so the link moves focus into it. The shell is a grid of two rows: the header's, and one that takes the rest, which the splitter fills. Each panel scrolls its own content, so the header stays in view.

const sidebarId = useId();
const navButton = useRef<HTMLButtonElement>(null);

<div className="shell">
  <Stack.Root as="header" direction="row" align="center" gap="condensed">
    <VisuallyHidden.Root focusable>
      <Link.Root href="#main">Skip to content</Link.Root>
    </VisuallyHidden.Root>
    <Button.Root ref={navButton} variant="ghost" aria-label="Navigation">
      …
    </Button.Root>
    <Text.Root as="span" weight="bold">
      Atlas
    </Text.Root>
    <Toolbar.Root aria-label="Account">…</Toolbar.Root>
  </Stack.Root>
  <Splitter.Root value={width} onValueChange={setWidth} min={180} max={360} collapsible>
    <Splitter.Panel id={sidebarId}>…</Splitter.Panel>
    <Splitter.Handle aria-label="Resize navigation" />
    <Splitter.Panel>
      <main id="main" tabIndex={-1}>
        …
      </main>
    </Splitter.Panel>
  </Splitter.Root>
</div>
.shell {
  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
  block-size: 100dvh;
}

The sidebar's width is the splitter's value, in pixels, so it keeps its width while the window changes and the main content flexes. With collapsible, Enter on the handle hides it and shows it again. The header's button does the same for people who don't find the handle: it sets the width you hold, with aria-expanded and aria-controls pointing at the panel. Enter's restore width is the splitter's own, so the button keeps its own last width, recorded on every change above 0. After a drag that collapses the sidebar, that is the width the drag passed last, min, where Enter restores the width from before the drag. Keep the width where your product keeps preferences and pass it back as value.

const [width, setWidth] = useState(240);
const [lastWidth, setLastWidth] = useState(240);
const resize = (next: number) => {
  if (next > 0) setLastWidth(next);
  setWidth(next);
};
const toggleSidebar = () => setWidth(width === 0 ? lastWidth : 0);

Narrow screens

Below the breakpoint, 48rem here, the sidebar moves into a Drawer.Root at the start edge, and the same header button opens it. The shell fills the window, so read the window's width with matchMedia. Then set show="end" on the splitter: it shows the main panel alone and takes the sidebar's panel and the handle out of the layout, with the main panel still mounted. Crossing the breakpoint, which zooming in can do, then keeps the page's scroll position, anything typed into it and focus inside it. Render the Nav.Root in one place at a time: the first panel while wide, the drawer while narrow.

The server has no window, so choose the layout it sends. Sending the narrow one, as here, doesn't split the main content on a phone, and a wide window switches as it hydrates, with nothing remounted, though the sidebar then appears after the first paint. A product that knows the width from a cookie or a client hint can send the right layout instead.

import { MenuIcon, PanelLeft } from 'lucide-react';

const WIDE = '(min-width: 48rem)';

function subscribe(onChange: () => void) {
  const query = window.matchMedia(WIDE);
  query.addEventListener('change', onChange);
  return () => query.removeEventListener('change', onChange);
}

function useWide() {
  return useSyncExternalStore(subscribe, () => window.matchMedia(WIDE).matches, () => false);
}

const wide = useWide();
const sidebarId = useId();
const navButton = useRef<HTMLButtonElement>(null);
const handle = useRef<HTMLDivElement>(null);
const sidebarHandoff = useFocusHandoff({ to: navButton });
// A window that widens shows the sidebar, so the drawer closes, and Drawer puts focus back on the button.
if (wide && drawerOpen) setDrawerOpen(false);
// `show` takes the handle out of the layout, so focus on it moves to the button.
useLayoutEffect(() => {
  if (!wide && handle.current === handle.current?.ownerDocument.activeElement) navButton.current?.focus();
}, [wide]);

<Button.Root
  ref={navButton}
  variant="ghost"
  aria-label="Navigation"
  {...(wide ? { 'aria-controls': sidebarId, 'aria-expanded': width > 0 } : { 'aria-haspopup': 'dialog' as const })}
  onClick={wide ? toggleSidebar : openDrawer}
>
  {wide ? (
    <Button.Icon>
      <PanelLeft />
    </Button.Icon>
  ) : (
    <Button.Icon>
      <MenuIcon />
    </Button.Icon>
  )}
</Button.Root>

<Splitter.Root show={wide ? 'both' : 'end'} value={width} onValueChange={resize} min={180} max={360} collapsible>
  <Splitter.Panel id={sidebarId}>{wide && <div ref={sidebarHandoff}>{nav}</div>}</Splitter.Panel>
  <Splitter.Handle ref={handle} aria-label="Resize navigation" />
  <Splitter.Panel>…</Splitter.Panel>
</Splitter.Root>

The button stays one element in both layouts, so it keeps focus when the layout switches under it. While wide it hides and shows the sidebar; while narrow it opens a dialog, which aria-haspopup="dialog" says. A sidebar that unmounts while it holds focus hands it to the button through useFocusHandoff, and focus on the handle, which stays mounted, moves to the button in a layout effect, so focus doesn't fall to the page when the window narrows.

Mark the current page's link with aria-current="page", which your router sets (see Nav). In the sidebar, focus stays on the link the person chose, so they can go on to the next one. Some routers announce a route change; with one that doesn't, announce the new page's title with useAnnouncer.

Close the drawer whenever the page changes, from one of its links or from Back, in the render that shows the new page. Key that check on everything a nav link can change: the path, the query string and, if your links use it, the hash. The link that was chosen goes away with the drawer, so send focus to the new page's heading: restoreFocusRef points at the heading when the page changed while the drawer was open, and at the header button otherwise, so Escape and the close button put focus back on the button, even after a link whose navigation hasn't arrived yet. Drawer reads the ref once it has closed, after the new page has rendered, so the heading is the new page's. The current page's link changes no page, so it closes the drawer itself, and focus goes back to the header button. A click with a modifier key opens the link elsewhere, so it leaves the drawer open.

const heading = useRef<HTMLElement>(null);
const [navigated, setNavigated] = useState(false);
// `url` comes from your router's location hook: the path, the query string and the hash.
const [shownUrl, setShownUrl] = useState(url);
if (url !== shownUrl) {
  setShownUrl(url);
  setNavigated(drawerOpen && !wide);
  setDrawerOpen(false);
}
const openDrawer = () => {
  setNavigated(false);
  setDrawerOpen(true);
};

<Drawer.Root open={drawerOpen} onOpenChange={setDrawerOpen} side="start" restoreFocusRef={navigated ? heading : navButton}>
  <Drawer.Header>
    <Drawer.Title>Navigation</Drawer.Title>
    <Drawer.Close />
  </Drawer.Header>
  <Drawer.Body
    onClick={(event) => {
      const elsewhere = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
      if (!elsewhere && (event.target as Element).closest('a[aria-current="page"]')) setDrawerOpen(false);
    }}
  >
    {!wide && nav}
  </Drawer.Body>
</Drawer.Root>

<Text.Root as="h1" ref={heading} tabIndex={-1}>
  {page.title}
</Text.Root>

Accessible composition

Todae handles: The nav landmark, its lists and the current link's look, in Nav.Root. The resizable sidebar: the handle's role, value, keys and pointer, Enter to collapse, and a collapsed panel hidden from the tab order and the accessibility tree, in Splitter. The main panel shown alone with both panels kept mounted, through show. A modal drawer that takes focus, closes on Escape, makes the page behind it inert and puts focus back on the button or on restoreFocusRef, in Drawer.Root. One Tab stop for the header's actions, in Toolbar.Root. A skip link shown while it has focus, in VisuallyHidden.Root focusable. Focus moving to the header button when the sidebar unmounts holding it, through useFocusHandoff.

You supply: The pages and their links, and which one is current. The header and main landmarks, the skip link and its target. Names for the nav, the drawer, the handle, the header button and the toolbar. The breakpoint and the layout the server sends. The nav in one place at a time. Closing the drawer when the window widens or the page changes, and where focus goes then. Moving focus off the handle before show hides it. Saving the sidebar's width.

Watch for: A different layout below the breakpoint in place of show, which remounts the page, so a person who zooms in loses their place and what they typed. A second copy of the nav anywhere in the page, such as in a closed drawer, which a change to how it is hidden turns into two nav landmarks of the same name. A drawer that stays open after the window widens or after Back. A drawer link that sends focus back to the header button, which leaves a screen reader at the top of the new page instead of its heading. A skip link target without tabIndex={-1}, which moves the page but not every screen reader's reading position. Focus left on the handle when the layout narrows: show takes it out of the layout, so focus falls to the page.

When to use

When: The frame of an app with several pages and navigation that stays in view, such as a dashboard, an admin or a workspace.

When not: A site with a few top-level pages → links in the header, as a nav. Views of one page → Tabs. Two panes the person resizes inside a page → Splitter alone. A side panel for a task beside the content → Drawer.