GitHub

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.

Stable .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.

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.

RoleRe-pointReach
.typography-body-md
  • --typography-body-md-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-body-md-font-size clamp(0.75rem, 0.66rem + 0.44vw, 0.9375rem) via --font-size-200
  • --typography-body-md-font-weight 350 via --typography-font-weight-regular → --font-weight-350
  • --typography-body-md-letter-spacing 0.01em via --letter-spacing-default
  • --typography-body-md-line-height 1.6 via --line-height-normal
+28 more components esa-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
  • --typography-microcopy-sm-subtle-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-microcopy-sm-subtle-font-size clamp(0.6875rem, 0.61rem + 0.38vw, 0.875rem) via --font-size-150
  • --typography-microcopy-sm-subtle-font-weight 350 via --typography-font-weight-regular → --font-weight-350
  • --typography-microcopy-sm-subtle-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-sm-subtle-line-height 1 via --line-height-none
+3 more components esa-file-list, esa-file-upload, esa-filter-dropdown
.typography-microcopy-xs
  • --typography-microcopy-xs-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-microcopy-xs-font-size clamp(0.625rem, 0.56rem + 0.32vw, 0.75rem) via --font-size-100
  • --typography-microcopy-xs-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-microcopy-xs-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-xs-line-height 1 via --line-height-none
+6 more components esa-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