App Shell

The frame of an app screen: header, sidebar, main content and footer. It fills the screen, Main scrolls between the bars, and the sidebar becomes a drawer on phones.
Stable
Web
iOS
Android
@advui/core
Source

Dashboard

Adv Data
Overview
Active sites128
Partners42
Reports due7
Households reached18.4k
On a phone the sidebar moves into a drawer: open it with the menu button.
Synced 2 minutes ago

Installation

pnpm
pnpm add @advui/core
npm
npm install @advui/core

Usage

import { AppShell, Sidebar } from '@advui/core'

<AppShell sidebarBreakpoint="md">
  <AppShell.Header>
    <AppShell.SidebarTrigger />
    …
  </AppShell.Header>
  <AppShell.Sidebar aria-label="Main">
    <Sidebar>…</Sidebar>
  </AppShell.Sidebar>
  <AppShell.Main>…</AppShell.Main>
  <AppShell.Footer>…</AppShell.Footer>
</AppShell>

Examples

Docs

`layout="sidebar-full"` with `sidebarBreakpoint="lg"`: the sidebar spans the height, and is a drawer below lg.
Docs
App ShellThe sidebar spans the full height of the screen, and the header sits beside it. Below the lg breakpoint the sidebar becomes a drawer.Only this column scrolls: the header, the sidebar and the footer stay in place.
Released under the MIT license.

API reference

AppShell

The screen: `100dvh` tall on web and `flex: 1` on iOS and Android (a `height` prop overrides it). Its children are the four areas, each at most once, as direct children; they can come in any order.
PropTypeDefaultDescription
layout'header-full' | 'sidebar-full''header-full'Which spans its whole edge: with `header-full` the header and footer span the width and the sidebar sits between them; with `sidebar-full` the sidebar spans the height and the header and footer sit beside it.
sidebarBreakpoint'sm' | 'md' | 'lg' | 'xl''md'Below this width the sidebar is a drawer from the left, opened by `AppShell.SidebarTrigger`.
drawerWidthnumber280Width of the drawer, in px, at most 85% of the screen.
sidebarOpenboolean—Whether the drawer is open (controlled). Always closed at desktop sizes.
defaultSidebarOpenbooleanfalseStarting `sidebarOpen` when uncontrolled.
onSidebarOpenChange(open: boolean) => void—Called with the new `sidebarOpen`, including `false` when the window widens past the breakpoint.
stickyHeaderbooleantrueKeep the header in place while Main scrolls. With `false` it scrolls away at the top of Main, so it spans Main’s column only.
safeAreabooleantruePad the edges the shell touches for the notch and home indicator. Turn it off when the shell sits below a navigation header or above a tab bar, which pad them already. The drawer always pads.iOS, Android only.
avoidKeyboardbooleantrueMain keeps the focused field above the keyboard, and taps on buttons work while the keyboard is up. Turn it off when the app already avoids the keyboard around the shell.iOS, Android only.

AppShell.Header

The top bar: a row with a border below, `<header>` (banner landmark) on web. Pads the top safe area on iOS and Android.

AppShell.Sidebar

The side area, a `<nav>` from the breakpoint up. It is as wide as its content, so a collapsible Sidebar inside still shrinks. Below the breakpoint its children move into the drawer. A Sidebar inside drops its own landmark, and in the drawer it fills the width and closes the drawer when an item is pressed.
PropTypeDefaultDescription
aria-labelstring'Main'Names the navigation landmark and the drawer.

AppShell.Main

The content, `<main>` on web. It sits in the area that scrolls (a ScrollView on iOS and Android) and fills it, so short content keeps the footer at the bottom. Add padding or a Container inside.

AppShell.Footer

The bottom bar, `<footer>` (contentinfo landmark) on web. It stays at the bottom edge; for a footer at the end of the content, put it at the end of Main instead. Pads the bottom safe area on iOS and Android.

AppShell.SidebarTrigger

An IconButton that opens the drawer, shown only below the breakpoint. It reports `aria-expanded` and `aria-controls`, and focus returns to it when the drawer closes. It renders nothing in a shell without a sidebar.
PropTypeDefaultDescription
aria-labelstring'Open navigation'The button’s accessible name.
size'sm' | 'md' | 'lg''md'Button size.

useAppShell()

The drawer state, `{ sidebarOpen, setSidebarOpen }`, for your own menu button or to close the drawer after navigating.

Accessibility

  • Web landmarks: the header is a banner, the sidebar a navigation landmark named by its aria-label, Main is main and the footer is contentinfo.
  • The drawer is a Drawer: a dialog named after the sidebar, with focus trapped inside while open and returned to the trigger on close. Escape, the overlay and its × button close it.
  • The scrolling area takes keyboard focus (web), so arrow keys and Page Down scroll Main without a mouse.
  • The desktop sidebar stays in the document below the breakpoint, hidden with CSS, so it is out of the accessibility tree there and the drawer is the only copy screen readers meet.
Keyboard interactions
KeyAction
Enter / SpaceOn the trigger: opens the drawer.
Tab / Shift+TabCycles focus inside the open drawer.
EscapeCloses the drawer.

Responsive behavior

Below sidebarBreakpoint the sidebar moves into a drawer and the trigger appears. On web that is CSS media queries, so server-rendered HTML is right at every width with no flash; on iOS and Android it follows the window size. A drawer left open closes when the window widens past the breakpoint.

Platform notes

Web
The shell is 100dvh tall and only Main scrolls, inside the shell, so the header, sidebar and footer need no sticky positioning. The desktop sidebar scrolls on its own when it is taller than the screen.
iOS
The shell is flex: 1 and Main is a ScrollView. The header pads the top safe area, the footer the bottom one, and the drawer every edge it touches; the insets come from UniversalProvider. With avoidKeyboard (since 0.15.0) Main insets its content by the keyboard and scrolls the focused field into view; the footer stays under the keyboard.
Android
The shell is flex: 1 and Main is a ScrollView. The back button closes an open drawer. Safe areas are padded as on iOS. Edge-to-edge apps no longer shrink for the keyboard, so with avoidKeyboard (since 0.15.0) Main ends where the keyboard starts, measured on screen, so a navigation header above needs no offset; Android then scrolls the focused field into view.
Since v0.11.0Last updated Nov 2, 2026Edit this page on GitHub