Application
App shell
The application frame: a sidebar column, a sticky topbar, a scrolling main region and an optional status strip. It owns the landmarks, the skip link, the responsive switch to off-canvas navigation and the scroll model.
import {
AppShell,
AppShellContent,
AppShellStatusBar,
AppShellTopbar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarItem,
SidebarMenu,
SidebarToggle,
} from "@luc/ui/app-shell";Two states, one component
Expanded is the default. The rail keeps the icons, keeps the labels as the accessible name, and reveals them with the existing tooltip. Ctrl/⌘ + B toggles it; the state persists locally, never in the URL.
Expanded
Main content scrolls the page. The sidebar scrolls itself, and the topbar never moves.
Collapsed rail
Hover an icon for its label. A parent item with children expands the rail before it opens the tree, so nested navigation stays reachable.
/* Both frames are one component. The doc frame only bounds the height. */
<AppShell
height="fill"
pathname={pathname} // router-agnostic: a string, not a router
renderLink={(props) => <Link {...props} />}
sidebarPersistKey="product:sidebar" // the collapsed state survives a reload
sidebar={
<>
<SidebarHeader>…</SidebarHeader>
<SidebarContent ariaLabel="Sections">
<SidebarGroup>
<SidebarGroupLabel>Research</SidebarGroupLabel>
<SidebarMenu>
<SidebarItem label="Datasets" href="/datasets" />
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarToggle /> {/* the rail's only control */}
</SidebarFooter>
</>
}
topbar={<AppShellTopbar>…</AppShellTopbar>}
statusBar={<AppShellStatusBar aria-label="Status">Updated 12:04 UTC</AppShellStatusBar>}
>
<AppShellContent>{page}</AppShellContent>
</AppShell>
/* The rail is the same element — one prop, not a second layout.
renderLink keeps the labels as the accessible name while the text is
hidden, so a collapsed item is still announced by voice. */
<AppShell defaultSidebarCollapsed ... />Composition
The shell takes slots, not a configuration object. Sidebar contents, topbar, status bar and the mobile navigation are all ordinary nodes.
<AppShell
pathname={pathname} // router-agnostic: a string, not a router
renderLink={(props) => <Link {...props} />}
sidebarPersistKey="product:sidebar"
sidebar={
<>
<SidebarHeader>…</SidebarHeader>
<SidebarContent ariaLabel="Sections">
<SidebarGroup>
<SidebarGroupLabel>Research</SidebarGroupLabel>
<SidebarMenu>
<SidebarItem label="Datasets" href="/datasets" />
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>…</SidebarFooter>
</>
}
topbar={
<AppShellTopbar>
<AppShellMobileTrigger />
<Breadcrumbs items={trail} />
</AppShellTopbar>
}
statusBar={<AppShellStatusBar>Updated 12:04 UTC</AppShellStatusBar>}
>
<AppShellContent>{page}</AppShellContent>
</AppShell>What the shell owns
Everything below is a rule, not a preference: a product that follows them cannot produce a shell that fights the system.
Landmarks and keyboard
- One named navigation landmark per sidebar (the scrolling region is the nav).
- One main region, one header, one optional status strip.
- A skip link that is visually hidden until focused and lands on main.
- Focus returns to the trigger when the mobile drawer closes.
Scroll model
- Sidebar: its own scroll region, so a long nav never moves the page.
- Topbar: sticky, opaque, one hairline underneath — never translucent.
- Main: the document scrolls by default; a workspace pane scrolls itself.
- Status strip: fixed, and it scrolls sideways rather than wrapping.
API
This page shows components working together rather than exporting one of its own, so the props live on the pages below — each link opens that page's API table.
- AppShell
app-shell - Workspace
workspace - URL state
url-state - OutlineDrawer
outline-drawer