Skip to content

App shell

A CSS grid with named areas — header, sidebar, main, footer — plus a small React context that wires <Navbar.MobileToggle> to the sidebar drawer. The composed pieces (navbar, sidebar, footer) also work standalone.

+--------------------------------------------+
| navbar |
+----------+---------------------------------+
| | |
| sidebar | main |
| (opt.) | |
| | |
+----------+---------------------------------+
| footer (opt.) |
+--------------------------------------------+
Page content
<div
class="app-shell"
style="min-height: 16rem; --color-system-accent: var(--color-purple-600)"
>
<header class="navbar">
<div class="navbar-brand">
<span class="brand-tile" aria-hidden>A</span>
Acme
</div>
</header>
<main class="app-shell-main" style="padding: 1rem">Page content</main>
</div>

Add hasSidebar for a two-column grid; a <Footer> drops into the bottom row automatically.

Acme
Page content
<AppShell
hasSidebar
systemAccent="var(--color-purple-600)"
style={{ minHeight: "20rem" }}
>
<Navbar>
<Navbar.Brand>
<BrandTile monogram="A" />
Acme
</Navbar.Brand>
</Navbar>
<Sidebar>
<Sidebar.Nav>
<Sidebar.Item href="#" active icon={IconHome}>
Dashboard
</Sidebar.Item>
<Sidebar.Item href="#" icon={IconReceipt}>
Orders
</Sidebar.Item>
</Sidebar.Nav>
</Sidebar>
<AppShell.Main style={{ padding: "1rem" }}>Page content</AppShell.Main>
<Footer>
<Footer.Meta>© Acme</Footer.Meta>
</Footer>
</AppShell>

48px-tall flex row: <Navbar.Brand> and <Navbar.Items> on the left, <Navbar.Actions> on the right. active on an item sets aria-current="page". Items accept a leading icon prop.

<header class="navbar" style="--color-system-accent: var(--color-purple-600)">
<div class="navbar-brand">
<span class="brand-tile" aria-hidden>A</span>
Acme
</div>
<nav class="navbar-items">
<a class="navbar-item" href="#" aria-current="page">
<i class="ti ti-home" aria-hidden="true"></i>
Dashboard
</a>
<a class="navbar-item" href="#">
<i class="ti ti-receipt" aria-hidden="true"></i>
Orders
</a>
<a class="navbar-item" href="#">Customers</a>
</nav>
<div class="navbar-actions">
<button class="btn btn-ghost btn-sm" type="button">Sign out</button>
</div>
</header>

<Navbar.Dropdown> is a <Menu> styled to fit the navbar.

<header class="navbar" style="--color-system-accent: var(--color-purple-600)">
<div class="navbar-brand">
<span class="brand-tile" aria-hidden>A</span>
Acme
</div>
<nav class="navbar-items">
<a class="navbar-item" href="#">Dashboard</a>
<details class="menu">
<summary class="menu-trigger navbar-item">Products</summary>
<div class="menu-popup" role="menu">
<button class="menu-item" type="button">Catalogue</button>
<button class="menu-item" type="button">Categories</button>
<hr class="menu-separator" />
<button class="menu-item" type="button">Imports</button>
</div>
</details>
</nav>
</header>

The vanilla example uses a native <select>; React’s <Select> is preferable when option rows need custom rendering (icons, two lines, etc.).

<header class="navbar" style="--color-system-accent: var(--color-green-600)">
<div class="navbar-brand">
<span class="brand-tile" aria-hidden>AO</span>
AO Retail
</div>
<div class="navbar-actions">
<select class="select select-sm" style="width: auto" aria-label="Shop">
<option value="billigvvs.dk">BilligVVS.dk</option>
<option value="lavprisvvs.dk">LavprisVVS.dk</option>
<option value="elproffs.se">ELproffs.se</option>
</select>
<details class="menu">
<summary class="menu-trigger navbar-item">Nickolaj</summary>
<div class="menu-popup" role="menu">
<button class="menu-item" type="button">Profile</button>
<hr class="menu-separator" />
<button class="menu-item" type="button">Sign out</button>
</div>
</details>
</div>
</header>

<Navbar.MobileToggle> is hidden at ≥ 48rem (Tailwind md) and flips <AppShell>’s mobile drawer state — it’s a no-op outside <AppShell>. The default aria-label is "Open menu"; override via label.

<button
class="navbar-mobile-toggle"
type="button"
aria-label="Open menu"
></button>

See mobile drawer below.

Flat items, tree groups, and click-to-collapse, driven by native HTML.

<Sidebar.Item> is a leaf link; active marks the current route, icon shows a leading glyph, badge adds a trailing count or pill. <Sidebar.Group> clusters items under an optional <Sidebar.GroupLabel> that hides when collapsed. <Sidebar.Header> is the slot for an app logo or product switcher above the nav.

<Sidebar style={{ height: "20rem" }}>
<Sidebar.Nav>
<Sidebar.Group>
<Sidebar.GroupLabel>Workspace</Sidebar.GroupLabel>
<Sidebar.Item href="#" active icon={IconHome}>
Dashboard
</Sidebar.Item>
<Sidebar.Item href="#" icon={IconReceipt} badge="12">
Orders
</Sidebar.Item>
</Sidebar.Group>
<Sidebar.Group>
<Sidebar.GroupLabel>Catalogue</Sidebar.GroupLabel>
<Sidebar.Item href="#" icon={IconPackage}>
Products
</Sidebar.Item>
<Sidebar.Item href="#" icon={IconChartBar}>
Categories
</Sidebar.Item>
</Sidebar.Group>
</Sidebar.Nav>
</Sidebar>

<Sidebar.Collapsible> is a <details> revealing <Sidebar.SubItem> rows. Pass defaultOpen to start expanded, or open + onOpenChange for controlled state. Override the trigger entirely with trigger.

<aside class="sidebar" style="height: 22rem">
<nav class="sidebar-nav">
<a class="sidebar-item" href="#">
<span class="sidebar-icon"
><i class="ti ti-receipt" aria-hidden="true"></i
></span>
<span class="sidebar-label">Ordrer</span>
</a>
<details class="sidebar-collapsible" open>
<summary class="sidebar-collapsible-trigger">
<span class="sidebar-icon"
><i class="ti ti-shopping-cart" aria-hidden="true"></i
></span>
<span class="sidebar-label">Webshop</span>
</summary>
<div class="sidebar-collapsible-panel">
<a class="sidebar-subitem" href="#" aria-current="page">CMS</a>
<a class="sidebar-subitem" href="#">Kampagner</a>
<a class="sidebar-subitem" href="#">Søgeord</a>
<a class="sidebar-subitem" href="#">Redirects</a>
</div>
</details>
<a class="sidebar-item" href="#">
<span class="sidebar-icon"
><i class="ti ti-package" aria-hidden="true"></i
></span>
<span class="sidebar-label">Lager</span>
</a>
</nav>
</aside>

<Sidebar.CollapseToggle> is a <label> wrapping a hidden checkbox; the rail responds to .sidebar:has(.sidebar-toggle:checked). Pass each item’s icon so it stays visible when collapsed.

React’s <Sidebar> exposes collapsed / defaultCollapsed / onCollapsedChange for controlled state.

<aside class="sidebar" style="height: 20rem">
<nav class="sidebar-nav">
<a class="sidebar-item" href="#" aria-current="page">
<span class="sidebar-icon">
<i class="ti ti-home" aria-hidden="true"></i>
</span>
<span class="sidebar-label">Dashboard</span>
</a>
<a class="sidebar-item" href="#">
<span class="sidebar-icon">
<i class="ti ti-receipt" aria-hidden="true"></i>
</span>
<span class="sidebar-label">Orders</span>
</a>
</nav>
<div class="sidebar-footer">
<label class="sidebar-collapse-toggle">
<input type="checkbox" class="sidebar-toggle" />
<span class="sr-only">Toggle sidebar</span>
</label>
</div>
</aside>

Below md the desktop sidebar hides and <Navbar.MobileToggle> opens it as a drawer. Esc, backdrop click, and link clicks all dismiss; focus is trapped while open. Override the drawer’s accessible label via <Sidebar drawerLabel="...">.

<AppShell> accepts mobileDrawerOpen / defaultMobileDrawerOpen / onMobileDrawerOpenChange for controlled drawer state — useful when an external trigger (a route guard, a tutorial step) needs to open it.

Acme
Resize below 768px and tap the hamburger.
<AppShell
hasSidebar
systemAccent="var(--color-purple-600)"
style={{ minHeight: "24rem" }}
>
<Navbar>
<Navbar.MobileToggle />
<Navbar.Brand>
<BrandTile monogram="A" />
Acme
</Navbar.Brand>
</Navbar>
<Sidebar>
<Sidebar.Nav>
<Sidebar.Item href="#" active icon={IconHome}>
Dashboard
</Sidebar.Item>
<Sidebar.Item href="#" icon={IconReceipt}>
Orders
</Sidebar.Item>
</Sidebar.Nav>
</Sidebar>
<AppShell.Main style={{ padding: "1rem" }}>
Resize below 768px and tap the hamburger.
</AppShell.Main>
</AppShell>

<Footer.Links> on the left, <Footer.Meta> on the right; both wrap on narrow viewports.

<footer class="footer">
<div class="footer-links">
<a class="footer-link" href="#">Docs</a>
<a class="footer-link" href="#">Status</a>
<a class="footer-link" href="#">Support</a>
</div>
<div class="footer-meta">v1.4.0 · © Acme</div>
</footer>
AO Retail
Nickolaj
Page content
<AppShell
hasSidebar
systemAccent="var(--color-green-600)"
style={{ minHeight: "32rem" }}
>
<Navbar>
<Navbar.MobileToggle />
<Navbar.Brand>
<BrandTile monogram="AO" />
AO Retail
</Navbar.Brand>
<Navbar.Actions>
<Select defaultValue="billigvvs.dk">
<Select.Trigger triggerSize="sm" aria-label="Shop">
<Select.Value />
<Select.Icon />
</Select.Trigger>
<Select.Popup>
<Select.Item value="billigvvs.dk">
<Select.ItemText>BilligVVS.dk</Select.ItemText>
</Select.Item>
<Select.Item value="lavprisvvs.dk">
<Select.ItemText>LavprisVVS.dk</Select.ItemText>
</Select.Item>
<Select.Item value="elproffs.se">
<Select.ItemText>ELproffs.se</Select.ItemText>
</Select.Item>
<Select.Item value="vvskupp.no">
<Select.ItemText>VVSkupp.no</Select.ItemText>
</Select.Item>
</Select.Popup>
</Select>
<Navbar.Dropdown label="Nickolaj">
<Menu.Item>Profile</Menu.Item>
<Menu.Separator />
<Menu.Item>Sign out</Menu.Item>
</Navbar.Dropdown>
</Navbar.Actions>
</Navbar>
<Sidebar>
<Sidebar.Nav>
<Sidebar.Item href="#" icon={IconSettings}>
Indstillinger
</Sidebar.Item>
<Sidebar.Item href="#" icon={IconReceipt}>
Ordrer
</Sidebar.Item>
<Sidebar.Item href="#" icon={IconHeadset}>
Kundeservice
</Sidebar.Item>
<Sidebar.Item href="#" icon={IconPackage}>
Produkter
</Sidebar.Item>
<Sidebar.Collapsible
defaultOpen
icon={IconShoppingCart}
label="Webshop"
>
<Sidebar.SubItem href="#" active>
CMS
</Sidebar.SubItem>
<Sidebar.SubItem href="#">Kampagner</Sidebar.SubItem>
<Sidebar.SubItem href="#">Søgeord</Sidebar.SubItem>
<Sidebar.SubItem href="#">Redirects</Sidebar.SubItem>
</Sidebar.Collapsible>
<Sidebar.Item href="#" icon={IconTruck}>
Lager
</Sidebar.Item>
<Sidebar.Item href="#" icon={IconChartBar}>
Statistik
</Sidebar.Item>
</Sidebar.Nav>
<Sidebar.Footer>
<Sidebar.CollapseToggle />
</Sidebar.Footer>
</Sidebar>
<AppShell.Main style={{ padding: "1rem" }}>Page content</AppShell.Main>
<Footer>
<Footer.Links>
<Footer.Link href="#">Docs</Footer.Link>
<Footer.Link href="#">Status</Footer.Link>
</Footer.Links>
<Footer.Meta>© AO Retail</Footer.Meta>
</Footer>
</AppShell>

No sidebar — primary navigation in the navbar via <Navbar.Dropdown>. For tools with few top-level destinations and per-destination tabs in main.

Insights
Nickolaj
Page content
<AppShell
systemAccent="var(--color-orange-600)"
style={{ minHeight: "28rem" }}
>
<Navbar>
<Navbar.Brand>
<BrandTile icon={IconChartBar} />
Insights
</Navbar.Brand>
<Navbar.Items>
<Navbar.Item href="#" active>
Dashboard
</Navbar.Item>
<Navbar.Dropdown label="Reports">
<Menu.Item>Sales</Menu.Item>
<Menu.Item>Returns</Menu.Item>
<Menu.Item>Inventory</Menu.Item>
<Menu.Separator />
<Menu.Item>Custom…</Menu.Item>
</Navbar.Dropdown>
<Navbar.Dropdown label="Customers">
<Menu.Item>Segments</Menu.Item>
<Menu.Item>Lifetime value</Menu.Item>
</Navbar.Dropdown>
<Navbar.Item href="#">Settings</Navbar.Item>
</Navbar.Items>
<Navbar.Actions>
<Navbar.Dropdown label="Nickolaj">
<Menu.Item>Profile</Menu.Item>
<Menu.Separator />
<Menu.Item>Sign out</Menu.Item>
</Navbar.Dropdown>
</Navbar.Actions>
</Navbar>
<AppShell.Main style={{ padding: "1rem" }}>Page content</AppShell.Main>
<Footer>
<Footer.Links>
<Footer.Link href="#">Docs</Footer.Link>
<Footer.Link href="#">Changelog</Footer.Link>
<Footer.Link href="#">Support</Footer.Link>
</Footer.Links>
<Footer.Meta>v2.1.0</Footer.Meta>
</Footer>
</AppShell>

<Container> goes inside <AppShell.Main>, which has no padding of its own, and stacks the page sections. For the search-and-filter row that usually sits above the table, see Tables › Filter toolbar.

Acme Admin

Orders today

128

14 awaiting fulfilment

Revenue

$8.4k

+8% vs target

Failed jobs

3

last 24h

OrderCustomerStatus
#1001Ada LovelaceShipped
#1002Grace HopperProcessing
#1003Alan TuringShipped
<AppShell
hasSidebar
systemAccent="var(--color-blue-600)"
style={{ minHeight: "30rem" }}
>
<Navbar>
<Navbar.Brand>
<BrandTile monogram="A" />
Acme Admin
</Navbar.Brand>
</Navbar>
<Sidebar>
<Sidebar.Nav>
<Sidebar.Item href="#" active icon={IconHome}>
Dashboard
</Sidebar.Item>
<Sidebar.Item href="#" icon={IconReceipt}>
Orders
</Sidebar.Item>
</Sidebar.Nav>
</Sidebar>
<AppShell.Main>
<Container>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard
icon={IconShoppingCart}
label="Orders today"
value="128"
detail="14 awaiting fulfilment"
/>
<StatCard
icon={IconCash}
label="Revenue"
value="$8.4k"
detail="+8% vs target"
/>
<StatCard label="Failed jobs" value="3" detail="last 24h" />
</div>
<Table striped>
<Table.Head>
<Table.Row>
<Table.HeaderCell>Order</Table.HeaderCell>
<Table.HeaderCell>Customer</Table.HeaderCell>
<Table.HeaderCell>Status</Table.HeaderCell>
</Table.Row>
</Table.Head>
<Table.Body>
<Table.Row>
<Table.Cell>#1001</Table.Cell>
<Table.Cell>Ada Lovelace</Table.Cell>
<Table.Cell>Shipped</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>#1002</Table.Cell>
<Table.Cell>Grace Hopper</Table.Cell>
<Table.Cell>Processing</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>#1003</Table.Cell>
<Table.Cell>Alan Turing</Table.Cell>
<Table.Cell>Shipped</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
</Container>
</AppShell.Main>
<Footer>
<Footer.Meta>© Acme</Footer.Meta>
</Footer>
</AppShell>

Two CSS variables on .app-shell set the rail width:

Variable Default What it controls
--app-shell-sidebar-w 240px Expanded sidebar / drawer width.
--app-shell-sidebar-w-collapsed 56px Width of the icon rail when collapsed.
.app-shell {
--app-shell-sidebar-w: 280px;
}

The navbar renders a 2px bottom stripe driven by --color-system-accent; the footer mirrors it with a matching top stripe. Setting that variable app-wide retints both. To tag several systems in one app, set it per shell instead — <AppShell systemAccent> in React, an inline style in vanilla — and drop a <BrandTile> into <Navbar.Brand>:

<header class="navbar" style="--color-system-accent: var(--color-purple-600)">
<div class="navbar-brand">
<span class="brand-tile" aria-hidden>OR</span>
Orders
</div>
</header>

For the derived tokens (-hover, -muted, -content) and the bright-accent contrast caveat, see Theming › System accent.

Four independent compounds. <AppShell> supplies only the grid and the mobile-drawer wiring — <Navbar>, <Sidebar> and <Footer> each work standalone.

Part Renders Class
AppShell <div> app-shell
AppShell.Main <main> app-shell-main
Navbar <header> navbar
Navbar.Brand <div> navbar-brand
Navbar.Items <nav> navbar-items
Navbar.Item <a> navbar-item
Navbar.Dropdown <details> (Menu) menu, with navbar-item on the trigger
Navbar.Actions <div> navbar-actions
Navbar.MobileToggle <button> navbar-mobile-toggle
Sidebar <aside> sidebar
Sidebar.Header <div> sidebar-header
Sidebar.Nav <nav> sidebar-nav
Sidebar.Group <div> sidebar-group
Sidebar.GroupLabel <div> sidebar-group-label
Sidebar.Item <a> sidebar-item
Sidebar.Icon <span> sidebar-icon
Sidebar.Label <span> sidebar-label
Sidebar.Badge <span> sidebar-badge
Sidebar.Collapsible <details> sidebar-collapsible
Sidebar.SubItem <a> sidebar-subitem
Sidebar.Footer <div> sidebar-footer
Sidebar.CollapseToggle <label> sidebar-collapse-toggle, sidebar-toggle
Footer <footer> footer
Footer.Links <div> footer-links
Footer.Link <a> footer-link
Footer.Meta <div> footer-meta
Part Prop Type Default
AppShell hasSidebar boolean false
AppShell systemAccent string (CSS color) inherited
AppShell mobileDrawerOpen boolean uncontrolled
AppShell defaultMobileDrawerOpen boolean false
AppShell onMobileDrawerOpenChange (open: boolean) => void
Navbar.Item active boolean false
Navbar.Item icon component or element
Navbar.Dropdown label ReactNode required
Navbar.MobileToggle label string "Open menu"
Sidebar collapsed boolean uncontrolled
Sidebar defaultCollapsed boolean false
Sidebar onCollapsedChange (collapsed: boolean) => void
Sidebar drawerLabel string "Navigation"
Sidebar.Item active boolean false
Sidebar.Item icon / badge component or element
Sidebar.Collapsible icon / label component or element
Sidebar.Collapsible trigger ReactNode icon + label
Sidebar.Collapsible open / defaultOpen boolean uncontrolled
Sidebar.Collapsible onOpenChange (open: boolean) => void
Sidebar.SubItem active / icon / badge as Sidebar.Item
Sidebar.CollapseToggle label string "Toggle sidebar"

active writes aria-current="page". Each part also takes the native attributes of its element, and Sidebar, Sidebar.Item, Sidebar.SubItem, Sidebar.Collapsible and Sidebar.CollapseToggle take classNames.

<Navbar.MobileToggle> and <Sidebar>’s drawer both read <AppShell>’s context, so the toggle is inert outside a shell. When the drawer opens, <Sidebar> moves its children into the drawer rather than duplicating them — state held in a sidebar child does not survive crossing that breakpoint.

Class Effect
app-shell min-height: 100vh grid, rows auto 1fr auto as areas header / main / footer
app-shell-with-sidebar Adds a leading column sized to the sidebar’s own width; below 48rem drops back to one column
app-shell-main Claims the main area with min-width: 0, so a wide table scrolls instead of stretching the grid
navbar 3rem flex row, 1rem side padding, muted surface, 2px bottom stripe in --color-system-accent
navbar-brand Semibold text-sm row that never shrinks
navbar-items Flex row, 0.125rem gap
navbar-item text-sm pill, fills on hover; aria-current="page" or [data-active] switches it to primary-on-muted
navbar-actions Flex row pushed right with margin-left: auto
navbar-mobile-toggle 2.25rem square hamburger drawn from one ::before bar plus two box-shadow strokes; hidden at ≥ 48rem
sidebar Column flex rail at --app-shell-sidebar-w (240px), muted surface, right border, 150ms width transition
sidebar-toggle 1px visually-hidden checkbox — .sidebar:has(.sidebar-toggle:checked) is what drives the collapsed state
sidebar-header 3rem row above the nav with a bottom border, for a logo or product switcher
sidebar-nav Scrolling column that fills the remaining height, 0.125rem gap
sidebar-group Column of items; a following group gets 0.5rem of top margin
sidebar-group-label text-xs uppercase muted heading; hidden when collapsed
sidebar-item text-sm row with a 0.5rem gap; active state adds a primary-muted fill and medium weight
sidebar-icon 1rem muted glyph box — the one part that stays visible in the collapsed rail; turns primary when active
sidebar-label Truncating text that fills the row; hidden when collapsed
sidebar-badge 1.25rem pill pushed to the row’s trailing edge; hidden when collapsed
sidebar-collapsible <details> wrapper; height animates via ::details-content and interpolate-size: allow-keywords
sidebar-collapsible-trigger <summary> styled as an item, native marker removed, CSS chevron rotating 45° when open
sidebar-collapsible-panel Sub-item column indented 1rem; hidden when collapsed
sidebar-subitem Like sidebar-item with tighter vertical padding and no icon box
sidebar-footer Bottom slot with a top border
sidebar-collapse-toggle 1.75rem square <label> whose CSS chevron flips direction when the inner sidebar-toggle is checked
sidebar-drawer Fixed leading panel at min(--app-shell-sidebar-w, 80vw), sliding in from fully off-screen
sidebar-drawer-backdrop Fixed scrim over the page that fades in with the drawer
footer Wrapping space-between row, text-xs muted, 2px top stripe in --color-system-accent
footer-links Wrapping row, 0.75rem gap
footer-link Muted, no underline; brightens and underlines on hover
footer-meta Muted text block

The grid areas are assigned by child class — .app-shell > .navbar, > .sidebar, > main, > .footer — so all four must be direct children. Wrapping one in a <div> drops it out of its area. app-shell-main exists for markup that can’t use a bare <main>; .app-shell > main already claims the area. Rail widths come from two custom properties, see Customization.

The collapsed rail needs no JavaScript: check the hidden sidebar-toggle and :has() does the rest. Write aria-current="page" yourself.

The mobile drawer is the one piece with no vanilla equivalent — sidebar-drawer and sidebar-drawer-backdrop are styled for Base UI’s [data-starting-style] / [data-ending-style] transition hooks. In vanilla, either leave the sidebar visible at every width or build the drawer from a <dialog class="drawer"> and toggle it yourself.