AppShell
<AppShell /> is the canonical product-layout primitive — the outermost frame for any
logged-in dashboard, admin console, docs site, or mail-style app. It composes four named
slots (header, sidebar, aside, footer) plus main content into a CSS-Grid-driven
shell with two layout variants, responsive sidebar collapse to a <Drawer> on mobile,
optional desktop rail-collapse, RTL-aware logical positioning, a skip-to-content link, and
a useAppShell() context hook for header components.
- Slot props, not subparts: header / sidebar / aside / footer are passed as props, so any component (yours, the DS's, a third party's) drops in.
- Two layout variants —
default(Notion / VS Code: sidebar full height) andinset(GitHub / Linear: header full width). - Responsive by default — below
sidebarBreakpoint(md), the sidebar moves into a<Drawer>; the header's hamburger button toggles it via the context hook. - Rail-collapse —
sidebarCollapsedswaps the sidebar between full and rail widths with a CSS transition. - Skip-to-content link, focusable on Tab, lands focus directly inside
<main>.
Overview — header, sidebar, footer dashboard
Anatomy
import { AppShell, useAppShell } from 'apx-ds';
function Hamburger() {
const { toggleSidebar } = useAppShell();
return <Button onClick={toggleSidebar} aria-label="Toggle menu">☰</Button>;
}
<AppShell
header={<TopBar />}
sidebar={<SidebarNav />}
footer={<Footer />}
>
<PageContent />
</AppShell>import { AppShell, useAppShell } from 'apx-ds';
function Hamburger() {
const { toggleSidebar } = useAppShell();
return <Button onClick={toggleSidebar} aria-label="Toggle menu">☰</Button>;
}
<AppShell
header={<TopBar />}
sidebar={<SidebarNav />}
footer={<Footer />}
>
<PageContent />
</AppShell>API
| Prop | Values | Default |
|---|---|---|
layout | default · inset | default |
header | ReactNode | — |
sidebar | ReactNode | — |
aside | ReactNode | — |
footer | ReactNode | — |
sidebarPosition | start · end (logical, RTL-aware) | start |
sidebarWidth | number (px) | 260 |
sidebarCollapsedWidth | number (px) | 64 |
sidebarCollapsed | boolean (controlled) | — |
defaultSidebarCollapsed | boolean | false |
sidebarBreakpoint | sm · md · lg · xl | md |
sidebarMobileOpen | boolean (controlled) | — |
defaultSidebarMobileOpen | boolean | false |
sidebarLabel | string | "Primary navigation" |
headerHeight | number (px) | 56 |
headerSticky | boolean | true |
headerVariant | default · bordered · floating | default |
headerOffset | number (px reserved above the grid) | 0 |
asidePosition | start · end | end |
asideWidth | number (px) | 320 |
asideOpen | boolean (controlled) | — |
defaultAsideOpen | boolean | true |
asideLabel | string | "Details" |
main | { padding, maxWidth, centered } | { padding: 6, maxWidth: 'full', centered: true } |
skipToContent | boolean | true |
skipToContentLabel | string | "Skip to content" |
main.padding accepts 0 \| 2 \| 4 \| 6 \| 8 \| 10 \| 12. main.maxWidth accepts
full \| sm \| md \| lg \| xl \| 2xl \| 7xl. Both are Tailwind-spacing-scale literal keys so
the JIT scanner picks every variant up at build time.
useAppShell() hook
Header / sidebar children call this hook to inspect or mutate shell state:
| Field | Type | Notes |
|---|---|---|
layout | AppShellLayout | Current layout variant |
sidebarPosition | start | end | Resolved sidebar logical position |
isMobile | boolean | Whether the viewport is below sidebarBreakpoint |
isSidebarCollapsed | boolean | Desktop rail-collapse state |
isSidebarOpen | boolean | Mobile drawer open state |
isAsideOpen | boolean | Aside open state |
toggleSidebar() | () => void | Smart: flips drawer on mobile, rail on desktop |
collapseSidebar() | () => void | Force desktop rail-collapse |
expandSidebar() | () => void | Force desktop rail-expand |
openSidebar() | () => void | Open the mobile drawer |
closeSidebar() | () => void | Close the mobile drawer |
toggleAside() | () => void | Flip aside open state |
openAside() | () => void | Open the aside |
closeAside() | () => void | Close the aside |
toggleSidebar() is the function header hamburger buttons should call — it picks the right
behavior automatically based on viewport size.
Layout variants
| Variant | Header position | Used by |
|---|---|---|
default | Header occupies the main column only; sidebar extends full height | Notion, Mantine, VS Code |
inset | Header spans every column; sidebar starts below | GitHub, Linear, Vercel |
Header scroll state
condense — the header becomes legible once content is behind it
<AppShell headerScroll="condense" header={<Brand />}>…</AppShell><AppShell headerScroll="condense" header={<Brand />}>…</AppShell>A pinned header that never changes on scroll reads as unfinished on a marketing page: it starts transparent over a hero, and once real content is behind it, it has to become a surface. condense gives it a border, a translucent background and a blur past the fold.
The listener lives here rather than in each template. Three templates writing it means three thresholds, three requestAnimationFrame loops, and three chances to forget the reduced-motion path.
| value | behaviour |
|---|---|
'none' | Inert. The default — an app shell's header is chrome, and changing it on scroll is a landing-page idiom. |
'condense' | Border, background and blur once past the fold. The house style for templates. |
'reveal' | As condense, plus hides on scroll down and returns on scroll up. |
reveal is available, and is not the house style
It removes the nav at the exact moment a fast-scrolling user is reaching for it, and it takes away the fixed orientation anchor some users rely on to know where they are. Fine as an option someone opts into; wrong as a default.
Its thresholds are asymmetric on purpose — 96px of committed downward scroll to hide, 8px upward to return. Hiding should need commitment; showing should feel instant, because scrolling up usually is the request for the nav. Travel is measured from where the current run of scrolling began rather than from the previous frame, so a trackpad's jitter or a momentum tail can't flip it.
Reduced motion
condense still runs. It's a state change that happens to be animated, and suppressing it would leave the header transparent over content it no longer sits on — a legibility bug, not a courtesy. What a reduced-motion user loses is the transition's smoothness, not the state.
reveal does not run, and degrades to condense. Chrome that moves itself off-screen is precisely the unrequested motion the preference is about, and a header that stays put is strictly more usable.
Styling your own content off the state
The header carries data-scrolled and data-hidden, so a logo that shrinks or a CTA that appears is your CSS hanging off the DS's listener — not a second listener next to it:
<Typography className="text-lg group-data-[scrolled]:text-base">Northwind</Typography><Typography className="text-lg group-data-[scrolled]:text-base">Northwind</Typography>Responsive behavior
- The
sidebarBreakpointprop (defaultmd= 768px) determines when the inline sidebar collapses into a<Drawer>. Above the breakpoint, the sidebar sits in the grid; below, it's removed from the grid and rendered into a drawer that the header hamburger opens. useAppShell().isMobilereflects this state — header components can show / hide a hamburger button accordingly.useAppShell().toggleSidebar()does the right thing on either side of the breakpoint: on mobile it toggles the drawer, on desktop it toggles the rail.
Examples
Basic — header + sidebar + footer dashboard
Inset — header spans every column
Collapsible — rail collapse via useAppShell()
With aside — controlled detail panel
Sidebar end — logical-end side panel
Floating header — detached chrome with shadow
Centered main — docs-site readability
Minimal — main only
Accessibility
- The
<main>element is the only true landmark; it carriestabIndex={-1}so the skip-to-content link can focus it directly without making it focusable to keyboard navigation in normal use. - The header is wrapped in
<header role="banner">. - The sidebar is wrapped in
<aside aria-label={sidebarLabel}>("Primary navigation"by default — override if the sidebar is something other than navigation). - The aside slot is wrapped in
<aside aria-label={asideLabel}>("Details"by default). - The footer is wrapped in
<footer role="contentinfo">. - The skip-to-content link is
sr-onlyuntil focused, then becomes a visible pill at the top-left of the viewport. Pressing Enter focuses and scrolls to<main>. - axe-core: zero violations across default / inset / sidebar-end / collapsed configurations.
RTL
sidebarPositionandasidePositionare logical (start/end), not physical (left/right). Underdir="rtl",startbecomes the right side.- The sidebar / aside inner borders use
border-inline-start/border-inline-endso the visible separation always lands between the panel and main content, regardless of writing direction.
Theming
AppShell reads from theme.components.AppShell.styleOverrides.{root, header, sidebar, aside, main, footer, skipLink}. Every slot shares the same component key so a single theme block
targets the whole family. Consumer className always wins via tailwind-merge.
Do / Don't
- Do pass slot props (
header={...}) — they're easier to reason about than nested children and the grid template is computed from "which slots are populated". - Do use
useAppShell()inside your header / sidebar to readisMobileand calltoggleSidebar()— that's the canonical hamburger button pattern. - Do set
main.maxWidthon docs / marketing surfaces to keep prose readable. - Don't nest one AppShell inside another. Use a
<Stack>/<Grid>for sub-layouts. - Don't override
<main>'s padding viaclassName— use themain.paddingprop so the recipe variant stays consistent and theme-overridable. - Don't add your own skip-link — AppShell ships one. Set
skipToContent={false}if you truly need to suppress it.