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.
Theming surface
Every public token this component reads, extracted from its source at build time,
split by blast radius: the hooks that belong to this component,
then the shared and system tokens it consumes alongside everything else. The
Resolves to column walks each token's real lineage —
component → semantic → primitive → raw value — so you
can see exactly where re-pointing it lands. A spoke re-skins by overriding these in
its theme-<slug>.css; component internals are never edited. No
hook for what you need? File it with /request-lego.
Type roles it names
This component does not set a size, weight, face or tracking of its own — it names a composite, and the composite carries all five. A composite is a class rather than a token, so none of it appears in the tables below; re-theme text by re-pointing the token family, not by editing the component.
| Role | Re-point | Reach |
|---|---|---|
.typography-body-md |
| +28 more componentsesa-breadcrumbs, esa-card, esa-checkbox, esa-checkbox-group, esa-collapsible, esa-combobox, esa-command-palette, esa-confirm-dialog, esa-container, esa-danger-zone, esa-dialog, esa-dropdown-menu, esa-empty-state, esa-entity-search, esa-form-field, esa-header-nav, esa-input-tag, esa-nav-dropdown, esa-page-header, esa-popover, esa-progress-bar, esa-radio-group, esa-select, esa-side-dialog, esa-snackbar-item, esa-switch-toggle, esa-tab-layout, esa-textarea |
.typography-microcopy-sm-subtle |
| +3 more componentsesa-file-list, esa-file-upload, esa-filter-dropdown |
.typography-microcopy-xs |
| +6 more componentsesa-button, esa-button-toggle, esa-filter-pills, esa-kbd, esa-pill, esa-range-slider |
Wired to this component
No other component reads these — re-point them and esa-app-shell is the
only thing that moves.
| Token | Tier | Resolves to |
|---|---|---|
--app-shell-brand-bg-hover | component | color-mix(in srgb, currentColor 8%, transparent) |
Shared tokens it reads
Owned by a family or by the semantic layer. Still overridable — but scope the
override (esa-foo { --form-border-color: … }) unless you mean to move
everything that reads it. Reach says what else that is.
| Token | Reach | Resolves to |
|---|---|---|
--focus-ring-color | component focus ring group · +34 more | #3e9b4f via --color-border-default-focus → --color-grass-10 |
--focus-ring-offset | component focus ring group · +31 more | 2px |
--focus-ring-width | component focus ring group · +35 more | 2px via --border-width-focus → --border-width-200 |
--border-width-default | semantic every component | 1px via --border-width-100 |
--color-background-default | semantic every component | #fcfcfc via --color-gray-1 |
--color-background-elevation-raised | semantic every component | #fcfcfc via --color-gray-1 |
--color-background-elevation-sunken | semantic every component | #f0f0f0 via --color-gray-3 |
--color-border-default | semantic every component | one role per property, so a theme can move the ring's weight and its colour independently. Tier 3's --focus-ring-width now reads this instead of holding a bare 2px, which is the same shape --focus-ring-color has always had (a thin tier-3 alias over a tier-2 role) — width was simply never given the tier-2 half.
*
* This role was rejected once, and the rejection is worth recording because its PREMISE was wrong rather than its reasoning. The argument against an `emphasis` width was that every 2px border in the kit is micro-geometry — spinner rings, the kbd keycap lip, a chevron. The focus ring was not in that enumeration and does not fit it: 33 components render it, more than read any other tier-3 token, and it is not micro-geometry but an accessibility affordance whose weight is a legitimate brand axis (a spoke that needs a heavier ring for contrast has to be able to reach it). It is also not a `border` in any component — every consumer paints it with `outline` or `box-shadow` — which is precisely why a survey of BORDERS missed it. The name still says `border` because tier 2 already calls the ring's colour --color-border-default-focus |
--color-border-default-strong | semantic every component | #bbbbbb via --color-gray-8 |
--color-content-brand | semantic every component | #2a7e3b via --color-grass-11 |
--color-content-default | semantic every component | #202020 via --color-gray-12 |
--color-content-default-muted | semantic every component | #646464 via --color-content-default-secondary → --color-gray-11 |
--color-content-default-secondary | semantic every component | #646464 via --color-gray-11 |
--radius-md | semantic every component | 0.5rem via --radius-200 |
--radius-sm | semantic every component | 0.25rem via --radius-100 |
--typography-font-family-display | semantic every component | 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans |
--typography-font-weight-bold | semantic every component | 650 via --font-weight-650 |
--font-size-300 | primitive every component | clamp(0.875rem, 0.77rem + 0.52vw, 1.125rem) |
--letter-spacing-tight | primitive every component | -0.01em |
--line-height-none | primitive every component | 1 |
--spacing-100 | primitive every component | 0.25rem |
--spacing-200 | primitive every component | 0.5rem |
--spacing-300 | primitive every component | 0.75rem |
--spacing-500 | primitive every component | 1.5rem |