Skip to content

Shell

hc-shell is the outer layout for an admin / business application: a persistent sidebar, a header, a scrolling main region, and an optional secondary aside and footer. The layout is pure CSS Grid. The single piece of JavaScript — installShell() — only powers the mobile navigation overlay, where the platform cannot manage focus and dismissal for us. On desktop the shell needs no script at all.

By default the header spans the full width above the sidebar (the SAP Fiori / Google / Salesforce arrangement); data-layout="sidebar-first" flips to a full-height sidebar (Slack / Notion / VS Code style) — see Layout modes.

It is built on the layout utilities and reuses the existing color and spacing tokens, so it adopts the active theme, density, and color automatically.

PrimitiveStatus
CSS Grid + grid-template-areasBaseline (all evergreen browsers)
100dvh dynamic viewport unitsBaseline 2023
:has() (the optional-aside third column)Baseline 2023

Author the regions in this source order — on mobile everything stacks in flow while the sidebar lifts out as an overlay, so the order matters:

Acme
Main content scrolls here, independently of the chrome.
© Acme

The header’s data-hc-shell-toggle button is hidden on desktop — it appears only on the mobile breakpoint.

By default the header and footer span the full width (top and bottom), bounding a middle band with the sidebar on the left — the arrangement used by SAP Fiori, Google Workspace, and Salesforce, where the global chrome carries product-wide concerns (brand, global search, account) around the contextual navigation. That is the demo above.

Set data-layout="sidebar-first" to flip it: the sidebar spans the full height on the left and the header and footer sit only over main — the Slack / Notion / VS Code style, which suits apps where the sidebar is the primary surface.

Acme
The sidebar runs full-height on the left; the header sits only above this column.
© Acme

Both modes share everything else — the collapsible icon rail, the optional aside, and the mobile off-canvas overlay all behave the same.

ClassElement (suggested)Role
hc-shell__header<header>Top bar; holds the mobile toggle.
hc-shell__sidebar<nav>Persistent navigation (full-height in the sidebar-first layout).
hc-shell__main<main>Primary content; scrolls independently.
hc-shell__aside<aside>Optional secondary panel (adds a third column).
hc-shell__footer<footer>Optional footer.
hc-shell__toggle<button>Mobile hamburger (use with data-hc-shell-toggle).

The shell deliberately doesn’t style the sidebar’s children — nav items are hc-items. Rendered as <a> elements they get the hover highlight and focus ring for free, and aria-current="page" on the active link gets the selected treatment (it is also the accessibility signal — set it server-side on the matching route):

Wrap the link text in .hc-shell__label (as above) and it hides automatically in the collapsed icon rail while staying in the accessibility tree. The Blocks page shows a full sidebar composed this way. Plain <a> children still work — they just carry no hover/current styling of their own.

The header is a flex row (gap included), so per-page extras compose without custom CSS: put a .hc-spacer where the content should split, and everything after it — a back link, action links, a badge — sits at the inline end:

<header class="hc-shell__header">
<button class="hc-button" data-variant="ghost"
data-hc-shell-toggle aria-label="Open navigation" type="button"></button>
<strong>Outbox events</strong>
<span class="hc-badge" data-variant="warning">3 pending</span>
<span class="hc-spacer"></span>
<nav class="hc-cluster" aria-label="Page actions">
<a class="hc-button" data-variant="ghost" data-size="sm" href="/ops/console">← Back</a>
<a class="hc-button" data-size="sm" href="/ops/console/outbox/export">Export</a>
</nav>
</header>

Add an <aside class="hc-shell__aside"> as a direct child and the grid grows a third column automatically (detected with :has()). On mobile it drops below main in normal flow.

<div class="hc-shell">
<header class="hc-shell__header"></header>
<nav class="hc-shell__sidebar"></nav>
<main class="hc-shell__main"></main>
<aside class="hc-shell__aside"></aside>
</div>
  • Desktop (≥ 60rem): a CSS Grid filling the viewport (block-size: 100dvh). By default the header spans the full width with the sidebar below it; with data-layout="sidebar-first" the sidebar spans full height on the left instead. Either way main scrolls independently of the chrome.
  • Mobile (< 60rem): the regions stack in normal flow and the page scrolls. The sidebar becomes a fixed off-canvas overlay, the header sticks to the top, and the hamburger toggles the overlay.

A literal 60rem breakpoint is used (media-query conditions cannot read custom properties). This is the one place the system uses a viewport breakpoint rather than a container query — the shell, by definition, fills the viewport, so viewport width is its container width. The regions inside it still use the container-responsive layout utilities.

import { installShell } from '@hypermedia-components/core';
installShell(); // or the auto-init bundle: @hypermedia-components/core/behaviors

For a shell that has a [data-hc-shell-toggle] button and a .hc-shell__sidebar, installShell():

  • toggles data-sidebar="open" on the shell and keeps aria-expanded / aria-controls in sync;
  • moves focus into the sidebar on open and traps Tab within it;
  • closes on Escape, on a click outside the sidebar (the scrim), and on activating a link inside the sidebar — restoring focus to the toggle;
  • force-closes when the viewport grows back to desktop, so the overlay never gets stuck open.

It is idempotent, returns an uninstaller, and picks up shells added to the DOM later (via MutationObserver) — so it works with htmx-swapped content. It never touches the network.

On desktop, the sidebar can collapse to a narrow icon rail. Opt in with data-collapsible on the .hc-shell__sidebar, add a [data-hc-shell-collapse] button anywhere in the shell, and (optionally) data-persist="<key>" to remember the state in localStorage:

<div class="hc-shell">
<header class="hc-shell__header">
<button data-hc-shell-toggle >Menu</button>
<button data-hc-shell-collapse type="button" aria-label="Collapse sidebar"></button>
</header>
<nav class="hc-shell__sidebar" aria-label="Primary"
data-collapsible data-persist="app.sidebar">
<ul>
<li><a href="/"><span aria-hidden="true"></span>
<span class="hc-shell__label">Dashboard</span></a></li>
</ul>
</nav>
</div>

installShell() toggles data-sidebar-collapsed on the shell (the CSS narrows the grid column from --hc-shell-sidebar-width to --hc-shell-sidebar-collapsed-width, default 4rem) and keeps the button’s aria-expanded in sync. Wrap each nav item’s text in .hc-shell__label; in the rail it is visually hidden but kept in the accessibility tree, so the links keep their accessible names while only the icon shows.

Directional collapse icon. A chevron should point the way it will move — « (collapse) while expanded, » (expand) once collapsed. Wrap the glyph in .hc-shell__collapse-icon and it mirrors automatically when the rail collapses (keyed on data-sidebar-collapsed, no extra script):

<button data-hc-shell-collapse type="button" aria-label="Toggle sidebar">
<span class="hc-shell__collapse-icon">«</span>
</button>

Skip the wrapper for a non-directional glyph (e.g. , as above, or a hamburger) — it should stay put. The button’s accessible name can stay constant (“Toggle sidebar”); installShell() announces the open/closed state via aria-expanded.

This is desktop-only — below the responsive breakpoint the sidebar uses the off-canvas overlay instead, and the collapse button is hidden. With data-persist, the collapsed state restores on the next visit before first paint (localStorage failures degrade silently).

  • Use real landmarks: <header>, <nav aria-label="…">, <main>, <aside>, <footer>. The shell only provides layout.
  • The mobile overlay manages focus like a dialog: focus enters the sidebar, Tab is trapped, Escape dismisses, and focus returns to the toggle.
  • Give the toggle an accessible name (aria-label or visible text); installShell() adds a fallback label if neither is present.
Show the generated CSS variables

These are layout knobs, not themeable visual tokens — set them inline on the shell or in a wrapper rule. Colors and spacing come from the shared --hc-color-* / --hc-space-* tokens.

VariableDefaultControls
--hc-shell-sidebar-width16remSidebar column width (desktop) / overlay width (mobile, capped at 80vw).
--hc-shell-sidebar-collapsed-width4remSidebar column width when collapsed to a rail (data-sidebar-collapsed).
--hc-shell-aside-width20remAside column width.
--hc-shell-padvar(--hc-space-4)Inner padding of the regions.