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.
Composition
Rootis the outer frame (bg-base relative isolate flex h-full w-full flex-col overflow-clip). It fills its nearest sized ancestor; mergeclassName="fixed inset-0"to pin a real app shell to the viewport. The shell never scrolls; onlyBodydoes. It clips withoverflow-clip, notoverflow-hidden, for the same reasonContentdoes:hiddenis a scroll container that paints no scrollbar, so anything wider than the frame lets focus scroll the whole shell sideways. On a frame pinned withfixed inset-0that is the entire window, with no scrollbar and no gesture to bring it back.Noticeis 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.Workspaceis 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 aSidebar.Nav(or any other rail, on either side) beside theContentcard, and aSidePanelafter it. Themin-h-0is what letsBodyscroll 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.Contentis 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.Headeris the toolbar row,Content's first child (flex h-14 shrink-0with a hairline border beneath it). Its three slots hold theSidebar.Trigger, the Breadcrumb trail, and the page's actions. It ish-14standalone; with aSidebar.Headerin the shell it derives its height from the sidebar's--sidebar-header-heighttoken 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 byshrink-0, not bysticky: it sits outside the scroll container, so it cannot scroll away, cannot be overlapped by a page's ownsticky top-0, and does not steal height from a page asking forh-full. Optional standalone, but effectively required once you compose aSidebar(see Accessibility).HeaderStart,HeaderContent, andHeaderActionsareHeader'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, andHeaderActions(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.Bodyis 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'sMainlandmark by default (see Landmarks). See Sizing a page to the card for what its classes buy you. There is deliberately noFooterpart. 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 />insideBodycould never reach a shell-level footer anyway, because it would be a sibling of the region the route renders into. Pin it insideBodyinstead: Pinning a footer to the card.SidePanelRootis the renderless state owner for the side panel, the waySidebar.Rootowns the rail:open+onOpenChangecontrolled, ordefaultOpenuncontrolled, closed by default. WrapWorkspacewith it, or anything above both the trigger and the panel. Optional, with the panel.SidePanelis a second card afterContent, for an assistant or a details pane. It renders an<aside>with the card treatment. Open, it takes--app-layout-side-panel-widthbeside the card; closed, it collapses to nothing and goesinertwith its children still mounted; belowmobileBreakpointit floats over the card. See Side panel. Optional.SidePanelTriggeris the ghostIconButtonthat toggles the panel, the waySidebar.Triggertoggles the rail, witharia-expandedandaria-controlswired to it. One inHeaderActionsopens the panel; one inSidePanelHeaderwith a close icon closes it from inside.SidePanelHeaderis 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 asHeader, by the same derivation, so the two rows and the sidebar header's switcher row center on one line.SidePanelBodyis the panel's scroll region (scrollbar relative min-h-0 w-full flex-1 overflow-y-auto overscroll-none): the same box asBodywith no landmark, because the page's body already ownsmain.SidePanelFooteris 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 whereContenthas 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 themainlandmark (see Accessibility for why the default is a<div>).AppLayout.HeaderStart: rarely; the slot exists to carryshrink-0, and a control inside it brings its own element.AppLayout.HeaderContent:Breadcrumb.Rootitself, 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 themainlandmark: 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.
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.HeaderStartisshrink-0: it takes only the width its children need and holds the row's start.Sidebar.Triggergoes here.AppLayout.HeaderContentismin-w-0 flex-1: it takes every pixel the other two slots leave and gives width back first. The Breadcrumb trail goes here, andmin-w-0letsBreadcrumb.Listscroll sideways instead of pushing the actions off the card.AppLayout.HeaderActionsisml-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.SidePanelHeaderis 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 asAppLayout.Header, by the same derivation from the sidebar header's token, so the two rows and the switcher row center on one line.AppLayout.SidePanelBodyis the scroll region: the same box asAppLayout.Bodywith no landmark, because the page's body already ownsmain.AppLayout.SidePanelFooteris 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-widthbeside 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 theAppLayout.Workspacerow, 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, becauseflex-1cannot 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-basisalone 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-noneorduration-*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
mobileBreakpointor the rail changes.
- Below
mobileBreakpoint(lgby 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 goesinvisiblethe instant the slide ends (visibilityrides 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 toSidebar.Root'smobileBreakpoint, 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.Workspaceis the container namedapp-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 expandedSidebar.Nav, it is the--container-side-panel-lanetheme 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.SidePanelHeadertoward 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
Escapewhile focus is inside. An open combobox or popover inside the panel takes the firstEscape, and the next one closes the panel. The page beneath stays usable. A panel in the lane is a docked pane and ignoresEscape. - Covering, the panel is modal. The row's other children, the card and the rail, go
inertwhile it is open, so nothing beneath it is clickable or read, anduseFocusTrapwrapsTabinside 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.
Persisting the side panel to a cookie (SSR)
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:
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
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:
A route renders its header, its actions, and its 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:
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.
Why each class on AppLayout.Body is there, and what breaks without it:
min-h-0 flex-1gives it a definite height, soh-fullon a page resolves to exactly the card's interior. Before the toolbar moved out of the scroll container, an in-flowstickyheader took flow space, soh-fullresolved 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 ofmx-auto max-w-7xlwould 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-fullstates the width outright instead of leaning on the flex container's cross-axisstretch, so anmx-automerged ontoBodystill 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:cxis tailwind-merge-backed and the consumer'sclassNamewins, so a mergedw-*replaces it outright, while a mergedmax-w-*— a separate conflict group — survives beside it and clamps the scrollport. Constrain the page insideBody, notBodyitself.relativecontains absolutely-positioned page content: a toolbar floating over an editor resolves against the scrollport instead of escaping toAppLayout.Rootand 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.
Pinning a footer to the card
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:
min-h-fullon 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 becauseAppLayout.Bodyis a flex item withmin-h-0 flex-1: a definite height for100%to resolve against.mt-autoon the bar pushes it to the bottom of that column on a short page.sticky bottom-0on 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.hashwithelement.scrollIntoView(), which scrolls the nearest scrollable ancestor, so/endpoints#tlsscrolls the card correctly, with no extra work. document.querySelector("main").scrollTopworks, becauseMaincomposes onto the scrollport. Code that reads or writes the page's scroll position through themainlandmark keeps working.- Scroll position is not reset on navigation, and
preventScrollResethas 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.
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>:
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.
Accessibility
AppLayout.Headerrenders a plain<div>, not a<header>.AppLayout.Bodyis themainlandmark and the header is its sibling, so a<header>here would have no sectioning ancestor and would be the ARIAbannerlandmark, which a card toolbar is not. If you need one, compose your own element viaasChild.AppLayout.Noticeis deliberately not named Banner: it renders a plaindivand claims no landmark. If the notice content needs its own semantics, put them on the content.AppLayout.Bodyis theMainlandmark. RenderSkipToMainLinkfirst in the shell, so the landmark the skip link focuses is the scroll container (see Landmarks).- The shell never scrolls; only
AppLayout.Bodydoes. Focus scrolling behaves like any regular scroll container. BothAppLayout.RootandAppLayout.Contentareoverflow-cliprather thanoverflow-hiddenspecifically so neither can become a hidden scroll container:hiddenscrolls 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, withSidebar.TriggerinAppLayout.HeaderStartand the route's trail inAppLayout.HeaderContent. The sidebar can collapse at every breakpoint, andSidebar.Triggeris 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.SidePanelrenders an<aside>, thecomplementarylandmark. Name it witharia-label, so a landmark list reads "Assistant" and not "complementary". Closed, it isinert: nothing inside it is focusable or read, and its children stay mounted. Aninertelement cannot hold focus, so when the panel closes with focus inside it,AppLayout.SidePanelRootmoves focus to the firstAppLayout.SidePanelTriggeroutside 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 onEscape. Covering the card, it is modal: the card and the rail goinert, and a focus trap wrapsTabinside it; the landmark keeps its name.AppLayout.SidePanelTriggercarriesaria-expandedandaria-controlsfor 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:
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:
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:
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:
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.
All props from div, plus:
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:
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:
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:
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:
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.
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-noneturns the slide into a cut.not-data-hydrated:transition-noneholds it until one frame after hydration.- A merged
transition-noneorduration-*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:
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:
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:
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:
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:
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.
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.
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.
SIDE_PANEL_STATE_COOKIE_NAME
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.