App Shell
The canonical application chrome: neutral off-white surfaces layered by value, a full-height sidenav carrying the wordmark and its own collapse toggle, a centered omnibox, and a profile dropdown. A composition of esa-app-bar + esa-sidebar-nav + esa-entity-search + esa-dropdown-menu — it owns no new visual primitives.
Preview
The frame below is the real composition, running at the largest size this column allows. Collapse the rail from the chevrons beside the wordmark, expand Reports for accordion children, open the omnibox, and scroll the content region — the chrome never moves.
Open the demo at full size
Opens in a new tab at height: 100dvh — an app shell judged in a doc column is judged at the one size it is never used at.
Dashboard
Field activity
Last 30 days
- Jun 24 Petaluma River mouth Water quality sampling Scheduled
- Jun 19 Hamilton Wetlands Avian nesting survey In progress
- Jun 16 Suisun Marsh Sediment sampling Complete
- Jun 11 Mare Island Maintenance dredging Complete
- Jun 04 Tomales Bay Eelgrass transect Complete
Layout contract
Why the frame holds
Only this region scrolls — the bar and the rail are fixed to the frame.
<AppShell
title="Estuary"
active="dashboard"
items={navItems}
searchIndex={searchIndex}
mark={mark}
userName="Maya Okafor"
>
<h1>Dashboard</h1>
<!-- page content -->
</AppShell> House decisions baked in
This component exists to stop every prototype re-litigating the same four questions. It is opinionated on purpose — if you find yourself overriding these, that is a signal to talk about the house style, not to fork the shell.
- Brand color never fills the chrome. Surfaces are neutral off-whites layered by value (canvas < bar < rail). Brand appears on the active nav item and primary actions only.
accentadds a faint brand-tinted neutral wash to the bar — acolor-mix, not a fill. - The wordmark lives in the sidenav, at the top of the rail — not in the top bar.
- The sidenav runs the full height and owns its own collapse toggle. The rail is a sibling of the bar, not a region beneath it, so the wordmark sits at the top-left of the window and the bar spans only the content. The toggle rides in the rail's header row beside the wordmark — it used to be a separate button in the top-left of the bar, which meant this component re-implemented a control
esa-sidebar-navalready ships. - The omnibox is compact and centered, not a full-width search bar spanning the chrome.
Data in, not markup
Nav items, the user menu, and the search index are props, not slotted markup. They are
server-serialized into a JSON island that the shell's client script reads and hands to
esa-sidebar-nav, esa-dropdown-menu, and esa-entity-search.
That is what lets one shell definition sit in a layout and every page pass only its
active key.
icon takes an esa-icon registry name ('map-pin') or a raw
inline SVG string. active matches on key — never on label or href, so
renaming a page or moving a route doesn't silently unhighlight the nav.
const items = [
{ key: 'sites', label: 'Sites', href: '/sites', icon: 'map-pin', badge: 14, group: 'Monitoring' },
{ key: 'reports', label: 'Reports', icon: 'file-text', group: 'Monitoring', children: [
{ key: 'monthly', label: 'Monthly monitoring', href: '/reports/monthly' },
] },
];
<AppShell items={items} active="sites" /> Layout contract
The shell is layout-agnostic: it does not wrap BaseLayout or own
<html>. Render it inside a parent with a fixed height and the frame fills that
parent, with only <main> scrolling. Give it no height and the whole page
scrolls, chrome included — which is the one way to make it look broken.
The full-size demo is
exactly this markup with <body> as the fixed-height parent.
The logo slot replaces the mark + wordmark; mark takes a raw inline
<svg> for the common case. Content is rendered directly into the sidenav
header rather than through a nested <slot>, which is what previously failed
to project into esa-sidebar-nav's shadow DOM.
<div style="height: 100dvh;">
<AppShell title="Estuary" items={items}>
<!-- page content -->
</AppShell>
</div> API
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | 'App' | Wordmark text in the sidenav header. Not the document title. |
active | string | — | The `key` of the current nav item. Matching is by key, never by label or href. |
items | AppShellNavItem[] | [] | Navigation as data: label, key, href, icon, badge, group, children. Serialized into a JSON island the client script hands to esa-sidebar-nav. |
userMenu | AppShellMenuItem[] | DEFAULT_USER_MENU | Rows in the profile dropdown. `divider: true` inserts a rule; `variant: "danger"` tints a destructive row. |
userName | string | 'Account' | Label on the profile trigger. |
searchIndex | AppShellSearchEntry[] | [] | Omnibox index (id is a real in-app href, plus title, subtitle, category). Omit or leave empty to drop the search control entirely. |
searchPlaceholder | string | 'Search…' | Placeholder inside the omnibox trigger. |
mark | string | — | Raw inline SVG (a full <svg>…</svg>) for the sidenav logo mark, rendered directly into the sidenav header. |
brandHref | string | '/' | Where the wordmark links. |
accent | boolean | false | Adds a faint brand-tinted neutral wash to the bar — a color-mix, not a brand fill. |
collapsed | boolean | false | Start with the sidenav collapsed to an icon rail. |
id | string | — | Stable id base for the nav, menu, and search elements. Required when more than one shell is on a page. |
Slots
| Slot | Type | Default | Description |
|---|---|---|---|
default | slot | — | Page content. Rendered into <main> — the only region that scrolls. |
logo | slot | — | Replaces the mark + wordmark in the sidenav header. Content is rendered directly (no nested slot), so it projects through esa-sidebar-nav’s shadow boundary. |
Composed from esa-app-bar,
esa-button,
esa-sidebar-nav,
esa-entity-search, and
esa-dropdown-menu. The
three custom elements register themselves — no extra imports at the call site. To judge the
proportions at the size an app shell is actually used at, see
the full-size demo.
Accessibility
- The content region is a real
<main>and the bar renders as<header>, so the landmarks are correct without anything added at the call site. Don't nest another<main>in the slot. - The collapse toggle is
esa-sidebar-nav's own, and it sits inside the region it collapses — so it needs noaria-controls, and its label states the direction (Expand sidebar/Collapse sidebar) rather than the ambiguousToggle sidebar. Collapsing to the icon rail keeps every item reachable — labels move to tooltips, they are not dropped. - The omnibox binds
/as a document-level hotkey, skipped while focus is in an editable field. It is document-level by design (a global shortcut), so a page hosting a shell has given that key away — worth knowing before embedding one inside a larger document. - Nav items with
childrenrender as expandable parents rather than links. Give a parent both ahrefand children only if the parent is genuinely a destination. - Set
idwhen a page carries more than one shell — the nav, menu, and search ids derive from it, and duplicates break both the wiring and thearia-controlsreference.