Prototype Library

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.

Inherited .astro import EsaAppShell from '@esa/ecology/esa-app-shell.astro';

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.

Estuary

Dashboard

14
Monitored sites
across 3 counties
1,284
Active permits
+12 this quarter
3
Needs attention

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. accent adds a faint brand-tinted neutral wash to the bar — a color-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-nav already 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

PropTypeDefaultDescription
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

SlotTypeDefaultDescription
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 no aria-controls, and its label states the direction (Expand sidebar / Collapse sidebar) rather than the ambiguous Toggle 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 children render as expandable parents rather than links. Give a parent both a href and children only if the parent is genuinely a destination.
  • Set id when a page carries more than one shell — the nav, menu, and search ids derive from it, and duplicates break both the wiring and the aria-controls reference.