Skip to main content

App Layout

A viewport-locked application shell. The shell itself never scrolls: AppLayout.Content is a rounded card holding a toolbar and a page region, and AppLayout.Body (the page region) is the only scroll container in the whole shell and its main landmark, with overscroll-none so long pages never bounce or drag the chrome. Above it, AppLayout.Header is a toolbar row with three slots: the sidebar trigger, the breadcrumb trail, and the page's actions. It sits beside the scroll container, so it stays pinned without sticky. AppLayout.Notice is a full-window composition slot for app-wide messaging that pushes everything (including any sidebar) down. It can stack a rare maintenance or environment banner with a persistent account-alert bar. AppLayout.SidePanel is a second card that opens beside the page for an assistant or a details pane. It is a layout: it owns where things go, and app state (routing, sessions, data) never enters it.

The card reads the same way as every other mantle surface (Content wrapping Header + Body), which is exactly the shape of the column beside it (Sidebar.Nav) and of Dialog.Content, Sheet.Content, and Card.Root.

The canonical composition pairs the shell with the Sidebar. The two are deliberately decoupled (AppLayout never references sidebar state) but cohesive: wrap the shell in Sidebar.Root, place Sidebar.Nav as the first child of AppLayout.Workspace, and put Sidebar.Trigger in AppLayout.HeaderStart. The collapsed icon rail is entirely the sidebar's business: the shell markup does not change, and the rail shares the shell's bg-base surface, so the card gutter shows no seam. Every row here is wrapped in Sidebar.Tooltip, which is what labels the rail for pointer users once it collapses; it needs a TooltipProvider ancestor, which a real app mounts at its root. The demo runs in its own framed document, exactly like a real app: collapse the sidebar to its icon rail with the trigger (or ⌘B/Ctrl+B; the shortcut binds to the frame, not this page), hover a row to see its tooltip, and reopen it the same way, toggle the notice to see the strip push the whole shell (sidebar included) down, and switch the preview to the mobile viewport for the sheet presentation. The sidebar's footer stays put through all of it: the account-scoped rows every product shares, a Help menu row, and the account switcher row. The same sidebar can also show a second section of navigation: choose Account settings from that account menu and the header's product switcher becomes a back link while the body swaps to the settings nav: the shell markup is identical, the <nav> landmark renames itself, the icon rail keeps working, and the back link returns to the product page you left. The demo narrows the sidebar's mobileBreakpoint to md so the desktop panel survives the preview's desktop and tablet widths; ngrok's dashboards keep the lg default.

note

The full-shell demos on this page render inside framed previews: each is a separate document, exactly like a real app owning the viewport. That is why they can pin AppLayout.Root with className="fixed inset-0" (mantle's cx is tailwind-merge-backed, so the override is deterministic) and compose a real Main landmark (see Landmarks). When you embed the shell inside an existing page instead (as the inline Polymorphism example below does), drop both: AppLayout.Root fills its nearest sized ancestor by default.

Composition

AppLayout.Root├── AppLayout.Notice└── AppLayout.SidePanelRoot    └── AppLayout.Workspace        ├── AppLayout.Content        │   ├── AppLayout.Header        │   │   ├── AppLayout.HeaderStart        │   │   ├── AppLayout.HeaderContent        │   │   └── AppLayout.HeaderActions        │   │       └── AppLayout.SidePanelTrigger        │   └── AppLayout.Body        └── AppLayout.SidePanel            ├── AppLayout.SidePanelHeader            ├── AppLayout.SidePanelBody            └── AppLayout.SidePanelFooter
  • Root is the outer frame (bg-base relative isolate flex h-full w-full flex-col overflow-clip). It fills its nearest sized ancestor; merge className="fixed inset-0" to pin a real app shell to the viewport. The shell never scrolls; only Body does. It clips with overflow-clip, not overflow-hidden, for the same reason Content does: hidden is a scroll container that paints no scrollbar, so anything wider than the frame lets focus scroll the whole shell sideways. On a frame pinned with fixed inset-0 that is the entire window, with no scrollbar and no gesture to bring it back.
  • Notice is the full-window-width, vertical composition slot pinned above everything else (flex w-full shrink-0 flex-col). Stack a maintenance or environment banner and an account alert bar here; each brings its own colors while the slot pushes the whole shell down. Collapses to nothing when empty. Optional.
  • Workspace is the region where the user works: everything below the notice, laid out as a flex row of columns (relative flex min-h-0 min-w-0 flex-1). Place a Sidebar.Nav (or any other rail, on either side) beside the Content card, and a SidePanel after it. The min-h-0 is what lets Body scroll instead of the page. It owns no gutter, deliberately, so a rail stays flush against the window edge and a collapsed icon rail keeps its flush geometry. It fills the frame at any width.
  • Content is the card surface: bg-card border-card-muted relative m-(--app-layout-card-gutter,0.5rem) flex min-w-0 flex-1 flex-col overflow-clip rounded-xl border shadow-sm. It insets itself with its own margin (--app-layout-card-gutter), because the gutter has to belong to the card rather than to a wrapper: the only other element that could hold it also contains the rail. It does not scroll.
  • Header is the toolbar row, Content's first child (flex h-14 shrink-0 with a hairline border beneath it). Its three slots hold the Sidebar.Trigger, the Breadcrumb trail, and the page's actions. It is h-14 standalone; with a Sidebar.Header in the shell it derives its height from the sidebar's --sidebar-header-height token instead (the band that header's first row sits on), so the app switcher and the toolbar stay center-aligned as one row by construction, however many rows the sidebar header stacks below the band. It is pinned by shrink-0, not by sticky: it sits outside the scroll container, so it cannot scroll away, cannot be overlapped by a page's own sticky top-0, and does not steal height from a page asking for h-full. Optional standalone, but effectively required once you compose a Sidebar (see Accessibility).
  • HeaderStart, HeaderContent, and HeaderActions are Header's three slots, in that order. HeaderStart (shrink-0) holds the sidebar trigger, HeaderContent (min-w-0 flex-1) holds the breadcrumb trail and gives up width first, and HeaderActions (ml-auto shrink-0) holds the page's actions at the row's end. They carry the flex, so no call site sets it. See Header slots.
  • Body is the page region and the shell's only scroll container (scrollbar relative min-h-0 w-full flex-1 overflow-y-auto overscroll-none). Render your <Outlet /> here. It is mantle's Main landmark by default (see Landmarks). See Sizing a page to the card for what its classes buy you. There is deliberately no Footer part. A bar pinned to the bottom of the card is almost always a page's bar (an editor's validity line, a save bar), and a route rendering through an <Outlet /> inside Body could never reach a shell-level footer anyway, because it would be a sibling of the region the route renders into. Pin it inside Body instead: Pinning a footer to the card.
  • SidePanelRoot is the renderless state owner for the side panel, the way Sidebar.Root owns the rail: open + onOpenChange controlled, or defaultOpen uncontrolled, closed by default. Wrap Workspace with it, or anything above both the trigger and the panel. Optional, with the panel.
  • SidePanel is a second card after Content, for an assistant or a details pane. It renders an <aside> with the card treatment. Open, it takes --app-layout-side-panel-width beside the card; closed, it collapses to nothing and goes inert with its children still mounted; below mobileBreakpoint it floats over the card. See Side panel. Optional.
  • SidePanelTrigger is the ghost IconButton that toggles the panel, the way Sidebar.Trigger toggles the rail, with aria-expanded and aria-controls wired to it. One in HeaderActions opens the panel; one in SidePanelHeader with a close icon closes it from inside.
  • SidePanelHeader is the panel's toolbar row and its first child (flex shrink-0 items-center gap-2 border-b px-4): the title at the start, the close control at the end. It sits on the same band as Header, by the same derivation, so the two rows and the sidebar header's switcher row center on one line.
  • SidePanelBody is the panel's scroll region (scrollbar relative min-h-0 w-full flex-1 overflow-y-auto overscroll-none): the same box as Body with no landmark, because the page's body already owns main.
  • SidePanelFooter is the bar pinned to the panel's bottom edge (flex shrink-0 flex-col gap-2 px-3 pb-3), outside the scroll region: a composer, a save row, a status line. It draws no rule above itself: the composer frame inside it is the edge. The panel has a footer where Content has none, because the panel's author owns its whole subtree and no route renders through an <Outlet /> inside it. Optional.

Polymorphism

Twelve of the fifteen parts accept an asChild prop. When true, the part renders its single child instead of its default element, forwarding all class names, data-* attributes, and the ref onto that child.

Each has a reason a consumer would reach for it. AppLayout.SidePanelRoot renders nothing. AppLayout.SidePanel and AppLayout.SidePanelTrigger each render a fixed structure a Slot could not swap (a clip box around an inner column, and a wired IconButton with its tooltip), so neither takes asChild. Build a custom trigger from useSidePanel instead.

  • AppLayout.Root: a <form> when the whole shell is one submission surface, or your own themed frame.
  • AppLayout.Notice: an <aside> or a live region when the strip's content needs its own semantics.
  • AppLayout.Workspace: rarely; swap it when the columns row needs a different element or a wrapper component of your own.
  • AppLayout.Content: a <section> when the card is a labelled region of a larger document.
  • AppLayout.Header: a real <header>, but only when the shell does not own the main landmark (see Accessibility for why the default is a <div>).
  • AppLayout.HeaderStart: rarely; the slot exists to carry shrink-0, and a control inside it brings its own element.
  • AppLayout.HeaderContent: Breadcrumb.Root itself, so the trail's <nav> is the slot and the header holds one element fewer.
  • AppLayout.HeaderActions: a <form> when every action submits the same form.
  • AppLayout.Body: a plain <div> when the document already owns the main landmark: a docs demo, a test, a shell embedded in a page (see Landmarks).
  • AppLayout.SidePanelHeader: a <header>, since the panel is an <aside> and a header inside it scopes to the panel; or a <form> when the row is a search field and its submit control.
  • AppLayout.SidePanelBody: an <ol> when the panel is one list, such as a session history, so the scroll region is the list itself.
  • AppLayout.SidePanelFooter: a <form> when the bar is a composer, so the submit key and the send control share one submission.

This scroll region is a `<section>` — inspect it to see the merged classes and `data-slot="app-layout-body"`.

Composing the Main landmark works the same way; see Landmarks. (This page already has its own Main landmark, so the live example above uses a <section> instead.)

Header slots

AppLayout.Header is one flex row, and its three slots carry the flex. Compose them in order:

  • AppLayout.HeaderStart is shrink-0: it takes only the width its children need and holds the row's start. Sidebar.Trigger goes here.
  • AppLayout.HeaderContent is min-w-0 flex-1: it takes every pixel the other two slots leave and gives width back first. The Breadcrumb trail goes here, and min-w-0 lets Breadcrumb.List scroll sideways instead of pushing the actions off the card.
  • AppLayout.HeaderActions is ml-auto shrink-0: it takes only the width its children need, grows to the left as actions are added, and stays at the row's end even when the header renders no content slot. The page's actions go here, three at most.

The header's gap-2 spaces the three slots. Each slot repeats it between its own children, so a second control at the start or a second action at the end sits at the same spacing as the slots themselves. Grow the trail below and watch it scroll inside the content slot while the two ends hold their width. The shell is embedded and has no sidebar, so a back link takes the start slot that Sidebar.Trigger takes in an app shell.

Each route renders its own header, and One header per page is the canonical shell. Collapse the actions into one menu below the sidebar's mobile breakpoint. The app shell demo at the top of this page shows the collapse: switch its preview to the mobile viewport and the three icon buttons become one menu.

Side panel

AppLayout.SidePanel is a second card after AppLayout.Content: an assistant, a details pane, or any surface that opens beside the page and closes again. It renders an <aside>, so it is the complementary landmark; name it with aria-label. It takes the same card treatment as the content card and reads like a Sheet with no focus trap and no backdrop, because the page beside it stays usable:

  • AppLayout.SidePanelHeader is the toolbar row and the panel's first child: the title at the start (min-w-0 flex-1 truncate, so it gives up width before a control does) and the controls at the end, with the close control last. It sits on the same band as AppLayout.Header, by the same derivation from the sidebar header's token, so the two rows and the switcher row center on one line.
  • AppLayout.SidePanelBody is the scroll region: the same box as AppLayout.Body with no landmark, because the page's body already owns main.
  • AppLayout.SidePanelFooter is the bar pinned to the bottom edge, outside the scroll region: a composer, a save row, a status line. Optional.

The state lives in AppLayout.SidePanelRoot, the way the rail's lives in Sidebar.Root: open + onOpenChange controlled, or defaultOpen uncontrolled, closed by default. It is renderless, so wrap AppLayout.Workspace with it, or anything above both the trigger and the panel. AppLayout.SidePanelTrigger is the ghost IconButton that toggles it, with aria-expanded and aria-controls wired to the panel, the same shape as Sidebar.Trigger. Put one in AppLayout.HeaderActions to open the panel, and one more in AppLayout.SidePanelHeader with a close icon to close it from inside. A custom control composes the same state from useSidePanel.

  • Open, the panel takes --app-layout-side-panel-width beside the card (clamp(21rem, 24cqw, 27rem): a quarter of the row, never narrower than 336px and never wider than 432px) and the card absorbs the difference. The width reads the AppLayout.Workspace row, not the viewport, so a row narrower than the viewport holds the panel at its floor and the page keeps the rest. The panel is a fixed basis, never a share of the row, because flex-1 cannot interpolate to a width.
  • Closed, the panel collapses to nothing and goes inert, so nothing inside it is focusable, clickable, or read by assistive technology. Its children stay mounted: a conversation keeps its scroll position and its in-flight reply across a close and a reopen.
  • Between the two, the panel moves the way the rail collapses: flex-basis alone animates over 200ms (ease-out-strong). The <aside> is a clip box, and the card is an inner column pinned to the row's end inset at the open width, so the header, the body, and the footer never reflow or move; the box's start edge sweeps across them. A CSS transition reverses from wherever it is, so a second click mid-flight turns the panel around.
    • motion-reduce: turns the slide into a cut.
    • A merged transition-none or duration-* replaces the default.
    • The transition holds until one frame after hydration, so a state the client corrects after the server render snaps instead of sliding on page load.
    • When a close ends, the transition stays off until the next open. A closed panel snaps when the viewport crosses mobileBreakpoint or the rail changes.
  • Below mobileBreakpoint (lg by default), the panel floats over the card instead of taking width from it: pinned to the row's end edge inside the card gutter, min(24rem, 100cqw - 5rem) wide (24rem, or the row less a 5rem strip of page when the row is narrower), with a larger shadow. Closed, it slides off the end edge and goes invisible the instant the slide ends (visibility rides the transition), so a row narrower than the frame has no panel strip beside it. One breakpoint lower it covers the card from gutter to gutter. With "sm" there is no lower breakpoint, so the panel covers the card from gutter to gutter as soon as it floats. Two cards side by side in 900px are two unusable cards. Match the prop to Sidebar.Root's mobileBreakpoint, so the rail and the panel change presentation at one width.
  • When the row is too narrow, the panel floats the same way whatever the viewport is. AppLayout.Workspace is the container named app-layout-workspace, and the panel leaves the row below the lane it needs. The lane depends on the rail beside the card. While a direct child of the row is an expanded Sidebar.Nav, it is the --container-side-panel-lane theme token (56.5rem: the expanded rail, the card at the panel's floor, the panel, and the three gutters). With a collapsed rail, no rail, or a rail that is a sheet, it is --container-side-panel-lane-icon-rail (46.75rem: the same row with the icon rail). The page is never narrower than the panel beside it: an expanded rail at a tablet width floats the panel instead of squeezing the page, and collapsing the rail returns the panel to the lane. A wider rail or panel raises the tokens in @theme.
  • A swipe closes it in every presentation. A drag on AppLayout.SidePanelHeader toward the end edge follows the pointer one to one: a floating panel slides, and a panel in the lane shrinks the way its close animation does, so the page grows under the finger. A release past 25% of the panel's width, or a flick, closes it; a shorter drag springs back, and a drag the wrong way meets rubber-band resistance.
  • Floating, the panel is an overlay the reader can also dismiss with Escape while focus is inside. An open combobox or popover inside the panel takes the first Escape, and the next one closes the panel. The page beneath stays usable. A panel in the lane is a docked pane and ignores Escape.
  • Covering, the panel is modal. The row's other children, the card and the rail, go inert while it is open, so nothing beneath it is clickable or read, and useFocusTrap wraps Tab inside it and moves focus into it on open. The one <aside> stays mounted across the lane, the float, and the cover, so a conversation keeps its DOM and the trap turns on and off with the viewport.

The panel keeps the card gutter on its top, end, and bottom edges and none on its start edge, so the card's own end margin is the gap between the two: one gutter wide, the same number as the inset from the window.

A closed panel is inert, and an inert element cannot hold focus. When the panel closes while focus is inside it (the close trigger in its header, say), the root moves focus to the first trigger outside the panel that can take focus, so a keyboard user lands back on the control that reopens it. The return runs on every close, whether a trigger, setOpen, or a controlled parent closed the panel. A trigger that CSS hides at the current breakpoint, or a disabled one, cannot take focus either, so a responsive header with one trigger per breakpoint lands on the visible one. Open the assistant from the header and close it from its own header. The demo narrows both breakpoints to md. The tablet preset is 48rem, narrower than the lane the panel needs beside an expanded rail, so the panel floats there; collapse the rail with its trigger and the panel returns to the lane beside the page. Switch the preview to the mobile viewport to see it cover the card.

An assistant can also be a page of its own. Assistant Across Routes renders one conversation in main on the assistant's route and in this panel on every other route, with a View Transition between the two.

A cookie is the only storage the server can read, so it is the only way to render the panel's state into the initial HTML with no first-frame correction at all. @ngrok/mantle/app-layout ships the pair for it, the same pair the sidebar ships for its rail (extractSidebarStateCookie): read the cookie in the loader, pass the result to defaultOpen, and write it back from onOpenChange.

This recipe has no live preview, because the thing it demonstrates is a server render: the value has to arrive from a loader, in the initial HTML, before any of this page's JavaScript runs. Here is the whole route module, a react-router 7 root, complete as written:

// app/root.tsximport {	AppLayout,	extractSidePanelStateCookie,	serializeSidePanelStateCookie,} from "@ngrok/mantle/app-layout";import { Sidebar } from "@ngrok/mantle/sidebar";import { SkipToMainLink } from "@ngrok/mantle/skip-to-main-link";import { SparkleIcon } from "@phosphor-icons/react/Sparkle";import { XIcon } from "@phosphor-icons/react/X";import { Outlet } from "react-router";import type { Route } from "./+types/root"; export const loader = ({ request }: Route.LoaderArgs) => ({	// `undefined` means "no preference yet", so pick your own default here	sidePanelOpen: extractSidePanelStateCookie(request.headers.get("Cookie")) ?? false,}); export default function Root({ loaderData }: Route.ComponentProps) {	return (		<Sidebar.Root>			<AppLayout.Root className="fixed inset-0">				<SkipToMainLink />				<AppLayout.SidePanelRoot					defaultOpen={loaderData.sidePanelOpen}					onOpenChange={(open) => {						document.cookie = serializeSidePanelStateCookie(open);					}}				>					<AppLayout.Workspace>						<Sidebar.Nav aria-label="Main">{/* your navigation */}</Sidebar.Nav>						<AppLayout.Content>							<AppLayout.Header>								<AppLayout.HeaderStart>									<Sidebar.Trigger />								</AppLayout.HeaderStart>								<AppLayout.HeaderActions>									<AppLayout.SidePanelTrigger icon={<SparkleIcon />} label="Toggle the assistant" />								</AppLayout.HeaderActions>							</AppLayout.Header>							<AppLayout.Body>								<Outlet />							</AppLayout.Body>						</AppLayout.Content>						<AppLayout.SidePanel aria-label="Assistant">							<AppLayout.SidePanelHeader>								<p className="text-strong min-w-0 flex-1 truncate text-sm font-medium">Assistant</p>								<AppLayout.SidePanelTrigger icon={<XIcon />} label="Close the assistant" />							</AppLayout.SidePanelHeader>							<AppLayout.SidePanelBody>{/* the conversation */}</AppLayout.SidePanelBody>						</AppLayout.SidePanel>					</AppLayout.Workspace>				</AppLayout.SidePanelRoot>			</AppLayout.Root>		</Sidebar.Root>	);}

defaultOpen is correct here, not controlled open. The value is already available at the server render and the hydration render, so the uncontrolled state initializes to the right value once and stays there; the server HTML ships data-state="closed" or "open" directly, with zero flash.

extractSidePanelStateCookie returns boolean | undefined rather than boolean on purpose: a first-time visitor with no cookie is not the same as someone who closed the panel, so choose the default explicitly with ?? false. serializeSidePanelStateCookie returns a plain cookie string, valid for both document.cookie on the client and a Set-Cookie header on the server. The helper omits Secure by default, so the same call persists over http://localhost. If your policy requires it, pass { secure: true }. There is no HttpOnly option: the browser has to write this cookie when the user toggles the panel.

With storage-backed persistence instead (useLocalStorage), use controlled open: the stored value is not available at the hydration render, so a defaultOpen read from it is wrong once. The panel holds its transition until one frame after hydration, so that correction snaps shut instead of sliding, the same way the rail does.

One header per page

important

Every page in an app that renders AppLayout renders one AppLayout.Header, and that header holds at minimum AppLayout.HeaderStart with Sidebar.Trigger and AppLayout.HeaderContent with the route's breadcrumb trail. This is a hard requirement. The sidebar can collapse at every breakpoint, and Sidebar.Trigger is the only way back from a collapsed rail or a closed mobile sheet.

The canonical shell gives each route its header. The shell renders AppLayout.Content around its <Outlet />, and each route renders AppLayout.Header and AppLayout.Body itself, so an action reads the page's own state. There is no wrapper component. The route composes the mantle parts directly, and every route repeats the three slots. The single-page demos on this page render the same two parts in the shell, and the one other composition is the deviation at the end of this section.

The shell renders the card and nothing inside it:

// app/routes/app-shell.tsximport { AppLayout } from "@ngrok/mantle/app-layout";import { Sidebar } from "@ngrok/mantle/sidebar";import { SkipToMainLink } from "@ngrok/mantle/skip-to-main-link";import { Outlet } from "react-router";import { AppSidebar } from "~/components/layout/app-sidebar"; export default function AppShell() {	return (		<Sidebar.Root>			<AppLayout.Root className="fixed inset-0">				<SkipToMainLink />				<AppLayout.Workspace>					<AppSidebar />					<AppLayout.Content>						<Outlet />					</AppLayout.Content>				</AppLayout.Workspace>			</AppLayout.Root>		</Sidebar.Root>	);}

A route renders its header, its actions, and its body:

// app/routes/endpoints.tsximport { AppLayout } from "@ngrok/mantle/app-layout";import type { BreadcrumbHandle } from "@ngrok/mantle/breadcrumb";import { Button } from "@ngrok/mantle/button";import { Sidebar } from "@ngrok/mantle/sidebar";import { Skeleton } from "@ngrok/mantle/skeleton";import { Suspense, useState } from "react";import { RouteBreadcrumbs } from "~/features/breadcrumbs/breadcrumbs";import { EndpointsTable } from "~/features/endpoints/endpoints-table";import { NewEndpointDialog } from "~/features/endpoints/new-endpoint-dialog"; export const handle = { breadcrumb: "Endpoints" } satisfies BreadcrumbHandle; export default function EndpointsPage() {	const [dialogOpen, setDialogOpen] = useState(false); 	return (		<>			<AppLayout.Header>				<AppLayout.HeaderStart>					<Sidebar.Trigger />				</AppLayout.HeaderStart>				<AppLayout.HeaderContent>					<RouteBreadcrumbs />				</AppLayout.HeaderContent>				<AppLayout.HeaderActions>					<Button						type="button"						appearance="filled"						intent="accent"						size="sm"						onClick={() => setDialogOpen(true)}					>						New endpoint					</Button>				</AppLayout.HeaderActions>			</AppLayout.Header>			<AppLayout.Body>				<Suspense fallback={<Skeleton className="m-6 h-32" />}>					<EndpointsTable />				</Suspense>				<NewEndpointDialog open={dialogOpen} onOpenChange={setDialogOpen} />			</AppLayout.Body>		</>	);}

AppLayout.Body is the Main landmark, so the skip link lands on the scrollport (see Landmarks). The trigger and the trail remount on every navigation. Neither holds state, and the sidebar's own state lives on Sidebar.Root, so nothing is lost.

Keep Suspense inside the body

If a route suspends, put the boundary inside AppLayout.Body, around the page content, as the route above does. A boundary above the header blanks the trigger and the trail until the data arrives, and a keyboard user loses the only way back to the sidebar. React Router awaits a route module before it commits a navigation, so only suspension inside the page component (a use() call, useSuspenseQuery, a lazy child) reaches a boundary. Never wrap the shell's <Outlet /> in a boundary whose fallback replaces the whole card.

The deviation: a shell-owned header

A shell can render AppLayout.Header once, above its <Outlet />, and derive each route's actions from a route handle. Header Actions from Routes documents it. It is secondary, and it costs something: the actions element renders outside the page, so it reads only the URL and loader data, and a route whose action needs page state cannot use it. Pick it only when an app already keeps its header in the shell and no route needs page state in its actions. Never mix the two in one app.

Standalone

The shell composes just as well without a sidebar: AppLayout.Workspace's columns are whatever you put there. Nothing in the shell knows the sidebar exists; the pairing above is composition, not coupling.

Sizing a page to the card

AppLayout.Body is a flex item that is block inside, and neither half is optional. Together they mean a route author never has to reason about the shell's geometry. The four page shapes each need at most one class on the route root:

Page shapeWhat the route root needs
Grow and scroll (default)nothing
Full bleednothing
Containedmx-auto max-w-7xl
Fill the cardflex h-full flex-col

Switch between all four shapes below: the shell markup never changes, only the classes on the page's own root. The example embeds the shell rather than pinning it with fixed inset-0, and passes asChild with a <div> to AppLayout.Body, because this docs page already owns the main landmark.

Row 1

Row 2

Row 3

Row 4

Row 5

Row 6

Row 7

Row 8

Row 9

Row 10

Row 11

Row 12

Why each class on AppLayout.Body is there, and what breaks without it:

  • min-h-0 flex-1 gives it a definite height, so h-full on a page resolves to exactly the card's interior. Before the toolbar moved out of the scroll container, an in-flow sticky header took flow space, so h-full resolved against the full content box. Every such page then overflowed by exactly the toolbar's height: a bottom-pinned bar hung below the fold on every load.
  • block inside, not flex. This is the trap worth knowing about even though the part now absorbs it: if the page region were a flex container, auto margins beat align-items: stretch, so a route root of mx-auto max-w-7xl would shrink-to-fit, getting narrower as the viewport gets wider. Keeping the page region block-level is what makes the most common page shape work at all.
  • w-full states the width outright instead of leaning on the flex container's cross-axis stretch, so an mx-auto merged onto Body still leaves it full width — auto cross-axis margins would otherwise turn a stretched item into a shrink-to-fit one. It is no floor, though: cx is tailwind-merge-backed and the consumer's className wins, so a merged w-* replaces it outright, while a merged max-w-* — a separate conflict group — survives beside it and clamps the scrollport. Constrain the page inside Body, not Body itself.
  • relative contains absolutely-positioned page content: a toolbar floating over an editor resolves against the scrollport instead of escaping to AppLayout.Root and painting across the sidebar rail.

Editor page

The fill the card shape at full scale, in its own framed document: a toolbar that stays put, an editor region that scrolls, floating actions positioned over it, and a status bar pinned to the card's bottom edge. Grow the policy with Add rules: the editor scrolls while the toolbar and status bar hold their positions.

The status bar in the example above is route-owned, and that is the only home it can have: a route renders through an <Outlet /> inside AppLayout.Body, so it can only ever add descendants of the page region. There is no AppLayout.Footer for it to reach, and it does not need one. A flex column and a sticky bar do the whole job, and the body stays the one scroll region:

Traffic policy

Rule 1

Rule 2

Rule 3

Rule 4

Traffic policy is valid — 4 rules
  • min-h-full on the page root makes it at least as tall as the card's interior, so a short page still reaches the bottom edge. This is the part that only works because AppLayout.Body is a flex item with min-h-0 flex-1: a definite height for 100% to resolve against.
  • mt-auto on the bar pushes it to the bottom of that column on a short page.
  • sticky bottom-0 on the bar keeps it on the card's bottom edge once the page is taller than the card and the body scrolls. Give the bar a background: the page scrolls under it.

Nothing here scrolls but AppLayout.Body. The skip link lands on main, which is the body, so Page Down after the jump scrolls the page. A page that nests its own overflow-y-auto region inside the body leaves main with nothing to scroll, and the jump strands a keyboard user on a landmark that does not move.

This composes to any depth. A route that owns a page header and tabs above a child route that owns an editor and its validity bar (ngrok's endpoint detail route) is the same shape twice: each level is a flex column that grows, and the bar at the leaf carries mt-auto and sticky bottom-0. Grow the policy with Add rules: the title and tabs stick to the top edge, the editor scrolls with the body, and the validity bar stays on the card's bottom edge.

Each level hands the next flex-1, never a height: Tabs.Content grows inside Tabs.Root, and the child route's root grows inside it, so mt-auto on the bar pushes it to the card's bottom edge on a short policy. Miss flex-1 at one level and the bar sits under the last line of content instead of on the edge. That is the way this shape goes wrong now, and it shows at once, because nothing overflows and nothing but the body scrolls.

Scroll restoration

AppLayout.Body is the scroll container, not the document, so React Router's <ScrollRestoration /> has nothing to restore. It saves window.scrollY and restores with window.scrollTo, and in this shell the window never scrolls (AppLayout.Root is pinned with fixed inset-0 and clips with overflow-clip). This is not new to the shell; it has always been true of any app whose scrolling happens in an inner container.

What that means in practice:

  • Hash links work. React Router resolves location.hash with element.scrollIntoView(), which scrolls the nearest scrollable ancestor, so /endpoints#tls scrolls the card correctly, with no extra work.
  • document.querySelector("main").scrollTop works, because Main composes onto the scrollport. Code that reads or writes the page's scroll position through the main landmark keeps working.
  • Scroll position is not reset on navigation, and preventScrollReset has no effect. Navigating from a scrolled-down list into a new route leaves the card where it was. If that matters, reset it yourself:

Scroll a page down, then switch pages: the card returns to the top. Navigation in the example is a fake router, so it can be embedded here; a real shell has no local pathname and keys the effect on useLocation().key instead, so a back/forward entry counts as a navigation too. The ref goes on the same element either way: this example passes asChild with a <div> because the docs page already owns the main landmark, while a real shell puts the ref on AppLayout.Body itself.

Scroll down, then switch pages

Endpoints row 1

Endpoints row 2

Endpoints row 3

Endpoints row 4

Endpoints row 5

Endpoints row 6

Endpoints row 7

Endpoints row 8

Endpoints row 9

Endpoints row 10

Endpoints row 11

Endpoints row 12

To restore rather than reset, save scrollportRef.current.scrollTop into sessionStorage keyed on location.key in the effect's cleanup, and read it back on the way in.

Landmarks

AppLayout.Body is the Main landmark. It renders mantle's Main, so it carries id="main" and tabIndex={-1}. Pair it with a SkipToMainLink as the first child of AppLayout.Root. Its default targetId matches that id. The framed demos on this page do exactly this: each preview is its own document, so each owns a real main.

The landmark is the scroll container, and that is what makes the skip link useful. The element the link focuses is the scrollport, so arrows, Space, and PageDown scroll the page straight away. It also keeps the toolbar out of the main landmark, which is what the skip link's own promise ("jump past the sidebar and toolbar") always implied. Never move the landmark to the AppLayout.Content card. A keyboard user who takes the skip link then lands on an element with no scrollable ancestor, and the keys do nothing.

Exactly one element in a document may be the Main landmark. When the shell is embedded in a page that already owns one (a docs page, a test, a widget inside another layout), pass asChild and render a plain <div>:

<AppLayout.Body asChild>	<div>{/* your page content */}</div></AppLayout.Body>

The part adds its classes and data-slot="app-layout-body" to your element and no landmark. Every inline example on this page does this, because the page you are reading owns the main.

The composition below is the canonical shell. It renders the frame, the skip link, and the content card, and each route renders AppLayout.Header and AppLayout.Body itself (see One header per page). It is the one example here with no inline preview beside it, deliberately: a document may contain only one main landmark and this docs page already owns one, and the shell pins itself over the whole viewport with fixed inset-0. The framed previews on this page are single pages, so each renders the same two parts in the shell.

import { AppLayout } from "@ngrok/mantle/app-layout";import { SkipToMainLink } from "@ngrok/mantle/skip-to-main-link";import { Outlet } from "react-router"; export function Shell() {	return (		<AppLayout.Root className="fixed inset-0">			<SkipToMainLink />			<AppLayout.Workspace>				<AppLayout.Content>					{/* each route renders AppLayout.Header and AppLayout.Body */}					<Outlet />				</AppLayout.Content>			</AppLayout.Workspace>		</AppLayout.Root>	);}

Accessibility

  • AppLayout.Header renders a plain <div>, not a <header>. AppLayout.Body is the main landmark and the header is its sibling, so a <header> here would have no sectioning ancestor and would be the ARIA banner landmark, which a card toolbar is not. If you need one, compose your own element via asChild.
  • AppLayout.Notice is deliberately not named Banner: it renders a plain div and claims no landmark. If the notice content needs its own semantics, put them on the content.
  • AppLayout.Body is the Main landmark. Render SkipToMainLink first in the shell, so the landmark the skip link focuses is the scroll container (see Landmarks).
  • The shell never scrolls; only AppLayout.Body does. Focus scrolling behaves like any regular scroll container. Both AppLayout.Root and AppLayout.Content are overflow-clip rather than overflow-hidden specifically so neither can become a hidden scroll container: hidden scrolls on focus with no scrollbar to scroll back, which a wide toolbar would trigger by translating the whole card (or, on the pinned frame, the whole window) out of view.
  • Every page renders one AppLayout.Header, with Sidebar.Trigger in AppLayout.HeaderStart and the route's trail in AppLayout.HeaderContent. The sidebar can collapse at every breakpoint, and Sidebar.Trigger is the only way back from a collapsed rail or a closed mobile sheet. A page with a collapsible sidebar and no visible trigger is unrecoverable. One header per page is the canonical shell that meets it.
  • AppLayout.SidePanel renders an <aside>, the complementary landmark. Name it with aria-label, so a landmark list reads "Assistant" and not "complementary". Closed, it is inert: nothing inside it is focusable or read, and its children stay mounted. An inert element cannot hold focus, so when the panel closes with focus inside it, AppLayout.SidePanelRoot moves focus to the first AppLayout.SidePanelTrigger outside the panel that can take focus. The return runs on every close, whether a trigger, setOpen, or a controlled parent closed the panel. A trigger that CSS hides at the current breakpoint, or a disabled one, cannot take focus either, so a responsive header with one trigger per breakpoint lands on the visible one (see Side panel). Floating, the panel closes on Escape. Covering the card, it is modal: the card and the rail go inert, and a focus trap wraps Tab inside it; the landmark keeps its name.
  • AppLayout.SidePanelTrigger carries aria-expanded and aria-controls for the panel, and a tooltip with its label, since it is icon-only. Keep one outside the panel at every breakpoint, or a closed panel has no way back open.

API Reference

Every part that renders an element accepts a ref prop that lands on that element and merges className through cx (tailwind-merge-backed, so consumer classes win). AppLayout.SidePanelRoot renders nothing and takes neither. Every rendering part stamps data-slot: app-layout on the root and app-layout-<part> in kebab-case on the rest (app-layout-side-panel-header, app-layout-side-panel-trigger), joined after any ancestor's chain. The tables below list only the attributes a part stamps beyond that.

AppLayout.Root

The outer frame of the shell: bg-base relative isolate flex h-full w-full flex-col overflow-clip. Fills its nearest sized ancestor; merge className="fixed inset-0" to pin a real app shell to the viewport. The shell itself never scrolls — only AppLayout.Body does. When the shell owns the document, render a SkipToMainLink as its first child (see Landmarks).

It is overflow-clip, not overflow-hidden, for the same reason AppLayout.Content is: hidden is a scroll container that paints no scrollbar, so anything wider than the frame lets focus scroll the entire shell sideways — translating the notice, the rail, and the card out of view with no scrollbar and no gesture to bring them back. On a frame pinned with fixed inset-0 that is the whole window. clip is not a scroll container at all.

All props from div, plus:

PropTypeDefaultDescription
asChild?booleanfalseUse the asChild prop to compose the AppLayout.Root styling onto alternative element types or your own components.

AppLayout.Notice

A full-window-width, vertical composition slot pinned above everything else in the shell — including any sidebar — for app-wide messaging. Stack a rare maintenance or environment banner and a persistent account alert bar here; each child brings its own colors and layout while the slot pushes the whole shell down. Renders an unstyled <div> (flex w-full shrink-0 flex-col) and collapses to nothing when empty. Deliberately not named Banner so it never reads as the ARIA banner landmark — it claims no landmark at all; give the notice content its own semantics if it needs them. Optional.

All props from div, plus:

PropTypeDefaultDescription
asChild?booleanfalseUse the asChild prop to compose the AppLayout.Notice styling onto alternative element types or your own components.

AppLayout.Workspace

The region where the user works: everything below the notice, laid out as a flex row of columns (relative flex min-h-0 min-w-0 flex-1). Place a Sidebar.Nav (or any other rail, on either side) beside the AppLayout.Content card, and an AppLayout.SidePanel after the card. The min-h-0 is what lets AppLayout.Body scroll instead of the page.

It owns no gutter, deliberately: the card insets itself with its own margin, so a rail stays flush against the window edge and a collapsed icon rail keeps its flush geometry.

It is relative, so a floating side panel resolves against the row and covers the card, never the notice. It is also the container named app-layout-workspace: the panel floats when the row is narrower than the lane it needs, so a page is never narrower than the panel beside it. The lane is the --container-side-panel-lane theme token while a direct child of the row is an expanded Sidebar.Nav, and --container-side-panel-lane-icon-rail with a collapsed rail or none.

All props from div, plus:

PropTypeDefaultDescription
asChild?booleanfalseUse the asChild prop to compose the AppLayout.Workspace styling onto alternative element types or your own components.

AppLayout.Content

The card surface beside the sidebar: bg-card border-card-muted relative m-(--app-layout-card-gutter,0.5rem) flex min-w-0 flex-1 flex-col overflow-clip rounded-xl border shadow-sm. Holds AppLayout.Header and AppLayout.Body. In the canonical shell it wraps the route's <Outlet />, and the route renders both (see One header per page).

The card does not scroll — AppLayout.Body does. It insets itself from the window edge and the rail with its own margin, driven by --app-layout-card-gutter, because the gutter has to belong to the card: the only other element that could hold it is AppLayout.Workspace, which also contains the rail, so padding there would push the sidebar off the window edge.

It is overflow-clip, not overflow-hidden, and the distinction matters: hidden is a scroll container that paints no scrollbar, so a toolbar wider than the card lets Tab scroll it sideways — translating the header and the page out of view with no scrollbar and no gesture to bring them back. clip is not a scroll container at all. It also keeps a full-bleed page background from painting square corners over the card's rounded-xl.

It is relative, so page content positioned with absolute resolves against the card instead of escaping to AppLayout.Root and painting across the sidebar rail.

All props from div, plus:

PropTypeDefaultDescription
asChild?booleanfalseUse the asChild prop to compose the AppLayout.Content styling onto alternative element types or your own components.
CSS VariableDefaultDescription
--app-layout-card-gutter0.5remThe gap between the card and the window edge, the notice above it, and the rail beside it (8px). AppLayout.Header subtracts twice this value when deriving its height, so overriding it keeps the toolbar aligned with the sidebar header's band. Set it on AppLayout.Root or another common ancestor, not on this part: AppLayout.SidePanel and AppLayout.SidePanelHeader read it too, and custom properties inherit downward only.

AppLayout.Header

The toolbar row at the top of the content card. Render it as AppLayout.Content's first child (border-card-muted flex h-14 shrink-0 items-center gap-2 border-b px-4), and compose its three slots in order: AppLayout.HeaderStart for the Sidebar.Trigger, AppLayout.HeaderContent for the Breadcrumb trail, and AppLayout.HeaderActions for the page's actions. The slots carry the flex, so no call site sets flex-1, min-w-0, shrink-0, or ml-auto itself. In the canonical shell, each route renders it (see One header per page).

It is pinned by construction, not by sticky: as a shrink-0 flex sibling outside the scrolling AppLayout.Body, it cannot scroll away, cannot be overlapped by a page's own sticky top-0, does not translate sideways when the page scrolls horizontally, and does not steal height from a page asking for h-full.

Renders a <div>, not a <header>. AppLayout.Body is the main landmark and this row is its sibling, so a <header> here would have no sectioning ancestor and would be the ARIA banner landmark, which a card toolbar is not. Optional standalone; effectively required once a Sidebar is composed (see Accessibility).

Sidebar alignment is an invariant, not a coincidence: standalone, the toolbar is h-14. When the shell contains a Sidebar.Header (detected with :has() from AppLayout.Root), the toolbar instead derives its height from the sidebar's public --sidebar-header-height token as calc(var(--sidebar-header-height, 4.5rem) - 2 * var(--app-layout-card-gutter, 0.5rem) - 2px) — subtracting twice the card gutter and twice the card's hairline border — which keeps this toolbar's vertical center on the band that token names, for any band height. The band belongs to the sidebar header's first row, so a header that stacks a second row grows downward while this toolbar keeps matching the switcher row alone. Override --sidebar-header-height on a common ancestor (e.g. AppLayout.Root's className) and both rows move together.

note

The alignment is a desktop-only coupling. Below the sidebar's mobileBreakpoint the sidebar renders inside a Sheet portaled to document.body — outside the shell subtree — so the :has() selector stops matching and this toolbar falls back to its standalone h-14. That is a 2px change (54px → 56px) applied after hydration, since the server renders desktop-first. There is nothing to align to on mobile anyway, but it explains the 2px if you are diffing layout across breakpoints.

All props from div, plus:

PropTypeDefaultDescription
asChild?booleanfalseUse the asChild prop to compose the AppLayout.Header styling onto alternative element types (e.g. a <header> when the shell does not own the main landmark) or your own components.
CSS VariableDefaultDescription
--sidebar-header-height4.5remRead (not set) by AppLayout.Header when the shell contains a Sidebar.Header, which owns the token — the band its first row sits on. Set it on a common ancestor of both (e.g. AppLayout.Root) so the two rows move together.
--app-layout-card-gutter0.5remRead (not set) by AppLayout.Header; owned by AppLayout.Content. The derived height subtracts twice its value, so changing the card gutter keeps the two rows aligned.

AppLayout.HeaderStart

The header's leading slot: flex shrink-0 items-center gap-2. It takes only the width its children need and holds the row's start while AppLayout.HeaderContent gives up width beside it. The home of Sidebar.Trigger, or of a back link in a shell with no sidebar.

All props from div, plus:

PropTypeDefaultDescription
asChild?booleanfalseUse the asChild prop to compose the AppLayout.HeaderStart styling onto alternative element types or your own components.

AppLayout.HeaderContent

The slot that fills the header: flex min-w-0 flex-1 items-center gap-2. It takes every pixel the other two slots leave and gives width back before either of them does. min-w-0 lets a Breadcrumb.List inside it scroll sideways instead of pushing the actions off the card. The home of the trail, or of a page title in a shell with no trail.

All props from div, plus:

PropTypeDefaultDescription
asChild?booleanfalseUse the asChild prop to compose the AppLayout.HeaderContent styling onto alternative element types or your own components.

AppLayout.HeaderActions

The header's trailing slot: ml-auto flex shrink-0 items-center gap-2. It takes only the width its children need, grows to the left as actions are added, and stays at the row's end even when the header renders no content slot. Keep it to three actions, and collapse them into one menu below the sidebar's mobile breakpoint. Header Actions from Routes shows the shape and derives the actions from the matched route.

All props from div, plus:

PropTypeDefaultDescription
asChild?booleanfalseUse the asChild prop to compose the AppLayout.HeaderActions styling onto alternative element types or your own components.

AppLayout.Body

The page region, and the shell's only scroll container: scrollbar relative min-h-0 w-full flex-1 overflow-y-auto overscroll-none. Render it after AppLayout.Header inside AppLayout.Content. In the canonical shell the route renders both, with the page content as the body's children (see One header per page).

It renders mantle's Main landmark: <main id="main" tabIndex={-1}> with data-slot="app-layout-body main", so a SkipToMainLink lands on the scroll container (see Landmarks). id and tabIndex are not props. When the document already owns a main, pass asChild and render a <div>. The part then adds its classes and data-slot="app-layout-body" to your element and no landmark.

Every class on it has a job; see Sizing a page to the card for what each one buys a route author, and Scroll restoration for what an inner scrollport means for your router.

All props from main except id and tabIndex, plus:

PropTypeDefaultDescription
asChild?booleanfalseUse the asChild prop to compose the AppLayout.Body styling onto alternative element types (a <div> when the document already owns the main landmark, a <section>) or your own components.

AppLayout.SidePanelRoot

The renderless state owner for the side panel, the way Sidebar.Root owns the rail. Wrap AppLayout.Workspace with it, or anything above both the trigger and the panel. Closed by default: a side panel is a surface the user opens. To persist the state across visits, read extractSidePanelStateCookie in the loader into defaultOpen and write serializeSidePanelStateCookie from onOpenChange (see Persisting the side panel to a cookie).

When the panel closes while focus is inside it, the root moves focus to the first AppLayout.SidePanelTrigger outside the panel that can take focus. A closed panel is inert and cannot hold focus, so without this the browser drops focus to the document. The return runs on every close, whether a trigger, setOpen, or a controlled parent closed the panel. A trigger that CSS hides at the current breakpoint, or a disabled one, cannot take focus either, so a responsive header with one trigger per breakpoint lands on the visible one.

PropTypeDefaultDescription
defaultOpen?booleanfalseWhether the panel starts open, when uncontrolled. Read once at mount, so pass the value the server read from a cookie here.
open?boolean—Controlled open state. Pair with onOpenChange.
onOpenChange?(open: boolean) => void—Called with the next open state whenever it changes. Write the cookie here.
children?ReactNode—The shell below it. The root renders no element of its own.

AppLayout.SidePanel

A second card after AppLayout.Content, for an assistant, a details pane, or any surface that opens beside the page and closes again. Render it as AppLayout.Workspace's last child, inside an AppLayout.SidePanelRoot that owns whether it is open. It renders an <aside>, the complementary landmark; name it with aria-label. id is not a prop: the root generates it, and AppLayout.SidePanelTrigger points aria-controls at it.

The <aside> is a clip box (relative isolate min-w-0 overflow-x-clip), and the card is an inner column pinned to the box's end inset at the open width, carrying the card treatment (bg-card border-card-muted rounded-xl border shadow-sm overflow-clip) and holding AppLayout.SidePanelHeader, AppLayout.SidePanelBody, and an optional AppLayout.SidePanelFooter, in that order. className lands on the clip box, not the card. The box keeps --app-layout-card-gutter on its top and bottom edges, none on its start edge, and holds the end inset inside itself, so the card's own end margin is the gap between the two and no margin ever animates.

The box is a fixed basis (grow-0 shrink-0, the open width plus the end inset), never a share of the row, so the card absorbs every change in width. Opening and closing animate flex-basis alone over 200ms (ease-out-strong), the way the rail collapses: the inner card never moves, and the box's start edge sweeps across it; a second click mid-flight reverses the run.

  • motion-reduce:transition-none turns the slide into a cut.
  • not-data-hydrated:transition-none holds it until one frame after hydration.
  • A merged transition-none or duration-* replaces the default through tailwind-merge.

Closed, it is basis-0 pointer-events-none and inert, with its children still mounted. Below mobileBreakpoint it floats over the card: absolute, pinned to the row's end edge inside the card gutter, min(24rem, 100cqw - 5rem) wide (24rem, or the row less a 5rem strip of page when the row is narrower), with shadow-lg and z-20. A closed floating panel slides off the end edge and goes invisible the instant the slide ends (visibility rides the transition), so a row narrower than the frame has no panel strip beside it. One breakpoint lower it covers the card from gutter to gutter. With "sm" there is no lower breakpoint, so the panel covers the card from gutter to gutter as soon as it floats. It also floats, with the strip treatment, when the AppLayout.Workspace row is narrower than the lane it needs, whatever the viewport is, so the page is never narrower than the panel beside it: --container-side-panel-lane (56.5rem) while a direct child of the row is an expanded Sidebar.Nav, and --container-side-panel-lane-icon-rail (46.75rem) with a collapsed rail or none. See Side panel.

A drag on AppLayout.SidePanelHeader toward the end edge swipes the panel away in every presentation: a floating panel slides, and a panel in the lane shrinks the way its close animation does. A floating panel is also an overlay: Escape closes it while focus is inside. A panel in the lane is a docked pane and ignores Escape. A covering panel is modal: the row's other children go inert while it is open, and useFocusTrap wraps Tab inside it and moves focus into it on open. The one <aside> stays mounted across the lane, the float, and the cover.

It does not take asChild: the panel is a fixed two-element structure (the clip box and the inner column), so no single element could be swapped.

All props from aside except id, plus:

PropTypeDefaultDescription
mobileBreakpoint?"sm" | "md" | "lg""lg"The viewport width below which the panel floats over the card instead of taking width beside it. One breakpoint lower it covers the card from gutter to gutter and turns modal; with "sm" there is no lower breakpoint, so it covers the card as soon as it floats by viewport. Match it to Sidebar.Root's mobileBreakpoint, so the rail and the panel change presentation at one width. The panel also floats when the row is narrower than the lane it needs (--container-side-panel-lane or --container-side-panel-lane-icon-rail), whatever this prop says.
CSS VariableDefaultDescription
--app-layout-side-panel-widthclamp(21rem, 24cqw, 27rem)The panel's width beside the card: a quarter of the AppLayout.Workspace row, never narrower than 336px and never wider than 432px. It reads the row, not the viewport, so a row narrower than the viewport holds the panel at its floor and the page keeps the rest. Set it on AppLayout.Root or on this part.
--app-layout-card-gutter0.5remRead (not set) by AppLayout.SidePanel; owned by AppLayout.Content. The panel keeps it on three edges, and the floating panel insets itself by it. Set it on AppLayout.Root or another common ancestor of the card and the panel, not on AppLayout.Content: custom properties inherit downward only, so a value on the card never reaches the panel.
--container-side-panel-lane56.5remA theme token the row's container query reads: the narrowest AppLayout.Workspace row that holds an expanded Sidebar.Nav (13rem), the card at the panel's floor (21rem), the panel (21rem), and the three gutters. Below it the panel floats while a direct child of the row is an expanded rail. Set it in @theme, not on an element.
--container-side-panel-lane-icon-rail46.75remThe same row with the icon rail (3.25rem) in place of the expanded one, or with no rail. Below it the panel floats whatever the rail is. Set it in @theme, not on an element.
Data AttributeValueDescription
data-state"open" | "closed"Mirrors the root's open state. The collapse and the transition key off it in CSS, and consumers style against it.
data-hydratedpresent one frame after hydrationPresence-only. The transition keys off its absence in CSS (not-data-hydrated:transition-none), so a state correction after the server render snaps instead of animating.

AppLayout.SidePanelTrigger

The button that toggles the side panel, the way Sidebar.Trigger toggles the rail. Renders a small ghost neutral IconButton with aria-expanded and aria-controls wired to the panel's <aside>. Place one in AppLayout.HeaderActions to open the panel, and one more in AppLayout.SidePanelHeader with a close icon to close it from inside: when that one closes the panel, the root moves focus to the one outside.

The button is icon-only, so it always renders a tooltip showing label. That means it requires a TooltipProvider ancestor, like any Tooltip.Root, and throws without one. Mount a single provider at your app root. It does not take asChild; a custom trigger composes the same state from useSidePanel.

All props from IconButton except asChild and children, with these defaults:

PropTypeDefaultDescription
appearance?"filled" | "ghost" | "outlined""ghost"The visual style of the trigger.
icon?ReactNode<SidebarSimpleIcon mirrored />The icon rendered inside the trigger.
intent?"neutral""neutral"The color intent of the trigger.
label?string"Toggle side panel"The accessible name, also shown in the tooltip.
onClick?MouseEventHandler<HTMLButtonElement>—Runs before the toggle. Call event.preventDefault() to keep the panel where it is.
size?"xs" | "sm" | "md" | "lg" | "xl""sm"The size of the trigger. Small, so it sits level with the other size="sm" controls in an AppLayout.Header row.
Data AttributeValueDescription
data-state"open" | "closed"Mirrors the root's open state. Pairs with aria-expanded.
data-slot"app-layout-side-panel-trigger icon-button"On the button: the part's slot, joined ahead of IconButton's own.
data-slot"app-layout-side-panel-trigger-tooltip tooltip-content"On the tooltip surface, not the button: the styling hook for the label popup this part renders, joined ahead of Tooltip.Content's own slot.
data-appearance"filled" | "ghost" | "outlined"Read, not stamped: the underlying IconButton reflects its appearance, which this part defaults to "ghost".
data-intent"neutral"Read, not stamped: the underlying IconButton reflects its intent, which this part defaults to "neutral".
data-size"xs" | "sm" | "md" | "lg" | "xl"Read, not stamped: the underlying IconButton reflects its size, which this part defaults to "sm".
data-icon-button"true"Read, not stamped: always present on the underlying IconButton.
data-loadingpresent when isLoadingRead, not stamped: the underlying IconButton stamps it by presence, never "false".
data-disabledpresent when disabledRead, not stamped: the underlying IconButton stamps it by presence, never "false", when it resolves disabled: disabled, aria-disabled="true", or isLoading with no explicit disabled.

AppLayout.SidePanelHeader

The toolbar row at the top of the side panel, its first child: border-card-muted flex shrink-0 items-center gap-2 border-b px-4, on the same band as AppLayout.Header. Standalone the band is h-14; with a Sidebar.Header in the shell it derives its height from --sidebar-header-height by the same formula, so the toolbar, this row, and the switcher row center on one line. Put the title first with min-w-0 flex-1 truncate, and the close control last. Merge border-b-0 for a header with no rule under it.

Renders a <div>, not a <header>: the row is a toolbar, not the panel's introduction, and it keeps one element shape with AppLayout.Header. A <header> inside the <aside> scopes to the panel and claims no landmark, so if the row introduces the panel, compose a <header> with asChild.

The row is the panel's swipe handle, through useSwipeToDismiss, in every presentation. A drag toward the end edge follows the pointer one to one: a floating panel slides, and a panel in the lane shrinks its basis the way its own close animation does, so the page grows under the finger. A release past 25% of the panel's width, or a flick, closes it; a shorter drag springs back on the panel's own 200ms ease-out, so motion-reduce: turns it into a cut, and a drag the wrong way meets rubber-band resistance. The row is touch-pan-y, so a vertical swipe still scrolls, and a tap on a control in the row stays a tap.

All props from div, plus:

PropTypeDefaultDescription
asChild?booleanfalseUse the asChild prop to compose the AppLayout.SidePanelHeader styling onto alternative element types or your own components.
CSS VariableDefaultDescription
--sidebar-header-height4.5remRead (not set) by AppLayout.SidePanelHeader when the shell contains a Sidebar.Header, the same way AppLayout.Header does.
--app-layout-card-gutter0.5remRead (not set) by AppLayout.SidePanelHeader; owned by AppLayout.Content. The derived height subtracts twice its value.

AppLayout.SidePanelBody

The side panel's scroll region, after the header: scrollbar relative min-h-0 w-full flex-1 overflow-y-auto overscroll-none. The same box as AppLayout.Body with no landmark, because the page's body already owns main. It is block inside, so a child that asks for h-full fills the panel between the header and the footer, and mx-auto max-w-* still centers.

All props from div, plus:

PropTypeDefaultDescription
asChild?booleanfalseUse the asChild prop to compose the AppLayout.SidePanelBody styling onto alternative element types or your own components.

AppLayout.SidePanelFooter

The bar pinned to the side panel's bottom edge, after the body: flex shrink-0 flex-col gap-2 px-3 pb-3. It sits outside the scroll region, so the body scrolls under it and it never scrolls away. It draws no rule above itself: the composer frame inside it is the edge. Merge flex-row justify-end for an action row. Optional.

The panel has a footer part where AppLayout.Content has none (see below), because the panel's author owns its whole subtree: no route renders through an <Outlet /> inside it, so a bar here is always the panel's own.

All props from div, plus:

PropTypeDefaultDescription
asChild?booleanfalseUse the asChild prop to compose the AppLayout.SidePanelFooter styling onto alternative element types or your own components.

useSidePanel

Read the nearest AppLayout.SidePanelRoot state. Throws when called outside one, so misuse fails loudly. Use it to build a custom trigger, a close control, or close-on-navigate behavior.

FieldTypeDescription
openbooleanWhether the panel is on screen.
setOpen(open: boolean) => voidSet the open state. Calls onOpenChange and, when uncontrolled, updates the internal state. Keeps one identity across renders, so it is safe in an effect dependency list.
toggle() => voidFlip the open state. This is what AppLayout.SidePanelTrigger calls.
panelIdstringThe generated id of the panel's <aside>, for aria-controls on a custom trigger.

extractSidePanelStateCookie

Parses the panel's open state out of a raw Cookie header, for cookie-backed persistence. Framework-agnostic: it takes a header string, so it works in a react-router loader, a Next.js route handler, or an Express middleware.

ParamTypeDescription
cookieHeaderstring | null | undefinedThe request's raw Cookie header.
Returnsboolean | undefinedtrue when open, false when closed, undefined when the cookie is absent, undecodable, or unparseable. Never throws.

serializeSidePanelStateCookie

Serializes the open state as a cookie string, valid for both document.cookie and a Set-Cookie header. No HttpOnly option: the browser writes this cookie from onOpenChange.

ParamTypeDefaultDescription
openboolean—Whether the panel is open.
maxAge?number31536000How long the preference survives, in seconds (one year).
path?string"/"Path the cookie applies to.
domain?string—Domain the cookie applies to; omitted by default, which scopes it to the current host.
sameSite?"Lax" | "Strict" | "None""Lax"SameSite policy.
secure?booleanfalseOmitted by default so the cookie is not rejected over http://localhost.

The cookie name the two helpers read and write ("mantle-side-panel-state"), exported so an app can clear it on sign-out or read it with its own cookie library.

There is no AppLayout.Footer

Deliberately. A bar pinned to the bottom of the card is almost always a page's bar — an editor's validity line, a save bar, a selection count — and a route rendering through an <Outlet /> inside AppLayout.Body could never reach a shell-level part, because it would be a sibling of the region the route renders into.

Pin it inside AppLayout.Body instead: Pinning a footer to the card. The side panel is the exception: AppLayout.SidePanelFooter exists because the panel's author owns its whole subtree.