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.

Marking the active item by URL — data-hc-nav-current

Section titled “Marking the active item by URL — data-hc-nav-current”

Setting aria-current="page" server-side on the matching route is the canonical approach. When that’s inconvenient — a static sidebar shared across pages, or one that persists across htmx swaps — add data-hc-nav-current to the nav container and the installNavCurrent() behavior marks the right link from the URL, with no inline JS (so it works under a strict Content-Security-Policy: default-src 'self'):

<nav class="hc-shell__sidebar" data-hc-nav-current aria-label="Primary">
<a class="hc-item" href="/app/explorer">Explorer</a>
<a class="hc-item" href="/app/docs">Docs</a>
<a class="hc-item" href="/app/docs/coverage">Coverage</a>
</nav>

Among the container’s same-origin a[href], it sets aria-current="page" on the link whose pathname equals location.pathname, or — failing an exact match — the longest link pathname that is a path-segment prefix of it, so a section link (/app/docs) stays current on its subpages (/app/docs/coverage). Root / matches only exactly, never as a prefix. It re-marks after htmx history navigation (htmx:pushedIntoHistory) and back/forward (popstate), and clears the link it previously set so exactly one is current.

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

data-hc-nav-current is opt-in and works on any nav container, not only the shell sidebar — pair it with .hc-item (or any element you style via [aria-current="page"]). With no JavaScript the links still work; only the active mark is missing — progressive enhancement.

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:

Outbox events3 pending
Everything after the spacer sits at the inline end of the 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.

Acme
Main content.
  • 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:

Acme
Press ⇔ — the labels hide, the icons remain.

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

The canonical htmx shell is a boosted app shell: data-hx-boost="true" on the shell turns every sidebar link into an AJAX navigation, and the inherited data-hx-target / data-hx-select make each one replace only the main region — the chrome (header, sidebar, its scroll position) never re-renders:

<div class="hc-shell" data-hx-boost="true"
data-hx-target=".hc-shell__main" data-hx-select=".hc-shell__main"
data-hx-swap="outerHTML show:window:top">
<header class="hc-shell__header"></header>
<nav class="hc-shell__sidebar" data-hc-nav-current aria-label="Primary">
<a class="hc-item" href="/orders">Orders</a>
<a class="hc-item" href="/settings">Settings</a>
</nav>
<main class="hc-shell__main"><!-- swapped per navigation --></main>
</div>

The server keeps returning full pagesdata-hx-select extracts the new page’s .hc-shell__main from the response, so direct loads, refreshes, and no-JS clicks work unchanged. Boosted navigations push the new URL into history, and installNavCurrent() re-marks on exactly that signal (htmx:pushedIntoHistory, plus popstate for back/forward) — so the data-hc-nav-current sidebar’s aria-current="page" follows every boosted navigation with no extra wiring.

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

The shell defines no component tokens of its own: colors and spacing come from the shared semantic --hc-color-* / --hc-space-* tokens, so themes, accents (data-color), and density apply automatically. Its CSS variables below are layout knobs, not themeable tokens.

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.