App Shell
Application layout primitive — collapsible sidebar, top header, scrollable main content.
AppShell is the top-level layout for application-style UIs. It owns the grid (sidebar + header over main), the show/hide state for the sidebar, and the desktop/mobile mode switch. Children compose the sidebar regions, header, and main content area.
Below the configured mobile breakpoint the sidebar transforms into a popover-driven off-canvas drawer, and the header auto-injects a hamburger toggle. The same <aside> element serves both modes — no duplicate DOM trees.
Usage
On desktop the sidebar toggles between fully visible and fully hidden — there is no icon-rail intermediate state. Below the mobile breakpoint it slides in as an off-canvas drawer.
<div class="zui-app-shell" data-zui-app-shell>
<a class="zui-app-shell-skip-link" href="#main">Skip to main content</a>
<aside class="zui-app-shell-sidebar" popover="auto" aria-label="Sidebar">
<div class="zui-app-shell-sidebar-header">
<i class="ph ph-sparkle" aria-hidden="true"></i>
<span>Acme</span>
</div>
<div class="zui-app-shell-sidebar-body">
<a href="#"><i class="ph ph-house"></i><span>Home</span></a>
</div>
<div class="zui-app-shell-sidebar-footer">
<i class="ph ph-user-circle"></i>
<span>Account</span>
</div>
</aside>
<header class="zui-app-shell-header">
<button type="button" class="zui-app-shell-toggle" aria-label="Toggle sidebar">
<i class="ph ph-list" aria-hidden="true"></i>
</button>
<strong>Dashboard</strong>
</header>
<main id="main" class="zui-app-shell-main">
<h1>Welcome</h1>
</main>
</div>import {
AppShell,
AppShellHeader,
AppShellMain,
AppShellSidebar,
AppShellSidebarBody,
AppShellSidebarFooter,
AppShellSidebarHeader,
} from '@mrmartineau/zui/react'
export function MyApp() {
return (
<AppShell shortcut>
<AppShellSidebar>
<AppShellSidebarHeader>
<i className="ph ph-sparkle" />
<span>Acme</span>
</AppShellSidebarHeader>
<AppShellSidebarBody>
<a href="#"><i className="ph ph-house" /><span>Home</span></a>
</AppShellSidebarBody>
<AppShellSidebarFooter>
<i className="ph ph-user-circle" />
<span>Account</span>
</AppShellSidebarFooter>
</AppShellSidebar>
<AppShellHeader>
<strong>Dashboard</strong>
</AppShellHeader>
<AppShellMain>
<h1>Welcome</h1>
</AppShellMain>
</AppShell>
)
}---
import {
AppShell,
AppShellHeader,
AppShellMain,
AppShellSidebar,
AppShellSidebarBody,
AppShellSidebarFooter,
AppShellSidebarHeader,
} from '@mrmartineau/zui/astro'
---
<AppShell shortcut>
<AppShellSidebar>
<AppShellSidebarHeader>
<i class="ph ph-sparkle"></i>
<span>Acme</span>
</AppShellSidebarHeader>
<AppShellSidebarBody>
<a href="#"><i class="ph ph-house"></i><span>Home</span></a>
</AppShellSidebarBody>
<AppShellSidebarFooter>
<i class="ph ph-user-circle"></i>
<span>Account</span>
</AppShellSidebarFooter>
</AppShellSidebar>
<AppShellHeader>
<strong>Dashboard</strong>
</AppShellHeader>
<AppShellMain>
<h1>Welcome</h1>
</AppShellMain>
</AppShell>import {
AppShell,
AppShellHeader,
AppShellMain,
AppShellSidebar,
AppShellSidebarBody,
AppShellSidebarFooter,
AppShellSidebarHeader,
} from '@mrmartineau/zui/solid'
export function MyApp() {
return (
<AppShell shortcut>
<AppShellSidebar>
<AppShellSidebarHeader>
<i class="ph ph-sparkle" />
<span>Acme</span>
</AppShellSidebarHeader>
<AppShellSidebarBody>
<a href="#"><i class="ph ph-house" /><span>Home</span></a>
</AppShellSidebarBody>
<AppShellSidebarFooter>
<i class="ph ph-user-circle" />
<span>Account</span>
</AppShellSidebarFooter>
</AppShellSidebar>
<AppShellHeader>
<strong>Dashboard</strong>
</AppShellHeader>
<AppShellMain>
<h1>Welcome</h1>
</AppShellMain>
</AppShell>
)
}<script lang="ts">
import {
AppShell,
AppShellHeader,
AppShellMain,
AppShellSidebar,
AppShellSidebarBody,
AppShellSidebarFooter,
AppShellSidebarHeader,
} from '@mrmartineau/zui/svelte'
</script>
<AppShell shortcut>
<AppShellSidebar>
<AppShellSidebarHeader>
<i class="ph ph-sparkle"></i>
<span>Acme</span>
</AppShellSidebarHeader>
<AppShellSidebarBody>
<a href="#"><i class="ph ph-house"></i><span>Home</span></a>
</AppShellSidebarBody>
<AppShellSidebarFooter>
<i class="ph ph-user-circle"></i>
<span>Account</span>
</AppShellSidebarFooter>
</AppShellSidebar>
<AppShellHeader>
<strong>Dashboard</strong>
</AppShellHeader>
<AppShellMain>
<h1>Welcome</h1>
</AppShellMain>
</AppShell><script setup lang="ts">
import {
AppShell,
AppShellHeader,
AppShellMain,
AppShellSidebar,
AppShellSidebarBody,
AppShellSidebarFooter,
AppShellSidebarHeader,
} from '@mrmartineau/zui/vue'
</script>
<template>
<AppShell shortcut>
<AppShellSidebar>
<AppShellSidebarHeader>
<i class="ph ph-sparkle"></i>
<span>Acme</span>
</AppShellSidebarHeader>
<AppShellSidebarBody>
<a href="#"><i class="ph ph-house"></i><span>Home</span></a>
</AppShellSidebarBody>
<AppShellSidebarFooter>
<i class="ph ph-user-circle"></i>
<span>Account</span>
</AppShellSidebarFooter>
</AppShellSidebar>
<AppShellHeader>
<strong>Dashboard</strong>
</AppShellHeader>
<AppShellMain>
<h1>Welcome</h1>
</AppShellMain>
</AppShell>
</template>For a richer standalone example with real content, see /app-shell-example.
Anatomy
AppShell
├── AppShellSidebar
│ ├── AppShellSidebarHeader (pinned, height = header)
│ ├── AppShellSidebarBody (scrolls)
│ └── AppShellSidebarFooter (pinned)
├── AppShellHeader (over main only — sidebar is full height)
└── AppShellMain (scrolls)
Props — AppShell
| Prop | Type | Default | Description |
|---|---|---|---|
position |
'left' | 'right' |
'left' |
Side the sidebar appears on. |
defaultCollapsed |
boolean |
false |
Initial collapsed state (uncontrolled). |
collapsed |
boolean |
— | Controlled collapsed state. |
onCollapsedChange |
(collapsed: boolean) => void |
— | Fired when collapse changes. |
mobileBreakpoint |
number |
768 |
Container width (px) below which mobile/drawer mode activates. |
storageKey |
string | null |
'zui-app-shell-collapsed' |
localStorage key. Pass null to disable persistence. |
shortcut |
boolean |
false |
When true, binds Cmd/Ctrl + B to toggle the sidebar. |
Props — AppShellHeader
| Prop | Type | Default | Description |
|---|---|---|---|
toggle |
boolean |
true |
Auto-inject the sidebar toggle button. |
toggleLabel |
string |
'Toggle sidebar' |
aria-label and tooltip text for the toggle button. |
Props — AppShellSidebar
| Prop | Type | Default | Description |
|---|---|---|---|
label |
string |
'Sidebar' |
aria-label for the navigation landmark. |
Mobile mode
Below the configured mobileBreakpoint (default 768px):
- The grid collapses to a single column (header + main only).
- The sidebar exits the grid and is presented as a popover-driven off-canvas drawer.
- The header's auto-injected toggle calls
showPopover()/hidePopover()on the sidebar. - The drawer dismisses on backdrop click and on Esc via the native popover API.
The mobile breakpoint is enforced by a viewport @media query. AppShell is designed to occupy the full viewport — embedding it inside a narrower parent will not switch to mobile mode unless the viewport itself is below the breakpoint.
Persistence and no-flash hydration
When storageKey is set (default), the collapse state is persisted to localStorage. The Astro wrapper ships an inline no-flash script that applies data-collapsed before the controller boots, preventing layout flicker on first paint. For other frameworks, import and inline appShellNoFlashScript(storageKey?) from @mrmartineau/zui/<framework>:
<script>{appShellNoFlashScript()}</script>
CSS custom properties
Set these on :root, on a wrapper, or on the element. See Component custom properties.
| Property | Default | Description |
|---|---|---|
--zui-app-shell-sidebar-width |
15rem |
Sidebar width on desktop |
--zui-app-shell-header-height |
3rem |
Header height |
--zui-app-shell-main-padding |
var(--space-md) |
Padding inside the main area |
--zui-app-shell-transition-duration |
200ms |
Duration of the sidebar collapse |
--zui-app-shell-bg |
var(--color-background) |
Background colour |
--zui-app-shell-surface |
var(--color-surface) |
Sidebar and header background |
--zui-app-shell-border |
var(--color-border) |
Border colour |
--zui-app-shell-sidebar-padding |
var(--space-xs) |
Padding inside the sidebar body |
--zui-app-shell-drawer-width |
min(20rem, 85vw) |
Sidebar width on mobile |
--zui-app-shell-toggle-size |
2rem |
Width and height of the toggle button |
--zui-app-shell-toggle-radius |
var(--radius-md) |
Border radius of the toggle button |
--zui-app-shell-toggle-font-size |
var(--step-1) |
Icon size in the toggle button |
To change the mobile breakpoint, re-author the @media (max-width: ...) rule in your own stylesheet — @media cannot read CSS custom properties.
Keyboard
- Esc closes the sidebar. On desktop it sets
data-collapsed="true". On mobile the popover API handles dismissal natively. Escape is ignored when focus is inside aninput,textarea,select, orcontenteditableelement. - Cmd/Ctrl + B toggles the sidebar when
shortcutis enabled onAppShell.
Toggle tooltip
Hovering or keyboard-focusing the header toggle shows a tooltip with the toggleLabel text. When shortcut is enabled, the tooltip also shows the shortcut: ⌘ B on Apple devices, Ctrl B elsewhere. Esc hides it.
The framework wrappers render the tooltip for you. In plain HTML, put this markup directly after the toggle button. The controller shows and hides it. The stylesheet shows ⌘ or Ctrl in the empty .zui-app-shell-toggle-mod key, so leave that element empty:
<span
popover="manual"
aria-hidden="true"
class="zui-tooltip-content zui-tooltip-placement-bottom zui-app-shell-toggle-tooltip"
>
Toggle sidebar
<kbd class="zui-kbd-group zui-app-shell-toggle-kbd">
<kbd class="zui-kbd zui-app-shell-toggle-mod"></kbd>
<kbd class="zui-kbd">B</kbd>
</kbd>
</span>
Accessibility
- Sidebar element is a
<aside>(or<nav>in your own composition) witharia-label. - Header is
<header>; main is<main>and is the target of the auto-injected skip link. - The toggle button carries
aria-controlsreferencing the sidebar id. - The toggle tooltip is
aria-hidden; the button'saria-labelalready names it. - All transitions honour
prefers-reduced-motion.