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.
Browser baseline
Section titled “Browser baseline”| Primitive | Status |
|---|---|
CSS Grid + grid-template-areas | Baseline (all evergreen browsers) |
100dvh dynamic viewport units | Baseline 2023 |
:has() (the optional-aside third column) | Baseline 2023 |
Structure
Section titled “Structure”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:
<div class="hc-shell"> <header class="hc-shell__header"> <button class="hc-button" data-variant="ghost" data-hc-shell-toggle aria-label="Open navigation" type="button">≡</button> <strong>Acme Admin</strong> </header>
<nav class="hc-shell__sidebar" aria-label="Primary"> <a href="/dashboard">Dashboard</a> <a href="/orders">Orders</a> <a href="/settings">Settings</a> </nav>
<main class="hc-shell__main"> <!-- page content --> </main>
<footer class="hc-shell__footer">…</footer></div>The header’s data-hc-shell-toggle button is hidden on desktop — it
appears only on the mobile breakpoint.
Layout modes
Section titled “Layout modes”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.
<div class="hc-shell" data-layout="sidebar-first"> <header class="hc-shell__header">…</header> <nav class="hc-shell__sidebar" aria-label="Primary">…</nav> <main class="hc-shell__main">…</main> <footer class="hc-shell__footer">…</footer></div>Both modes share everything else — the collapsible icon rail, the optional aside, and the mobile off-canvas overlay all behave the same.
Regions
Section titled “Regions”| Class | Element (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). |
Sidebar navigation items
Section titled “Sidebar navigation items”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):
<nav class="hc-shell__sidebar" aria-label="Primary"> <a class="hc-item" href="/ops"> <span class="hc-item__media" aria-hidden="true">⚙</span> <span class="hc-item__title hc-shell__label">Operations</span> </a> <a class="hc-item" href="/studio" aria-current="page"> <span class="hc-item__media" aria-hidden="true">▦</span> <span class="hc-item__title hc-shell__label">Studio</span> </a></nav>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.
Header actions and back link
Section titled “Header actions and back link”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>Optional aside
Section titled “Optional aside”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>Responsive behavior
Section titled “Responsive behavior”- Desktop (≥ 60rem): a CSS Grid filling the viewport
(
block-size: 100dvh). By default the header spans the full width with the sidebar below it; withdata-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.
Behavior — installShell()
Section titled “Behavior — installShell()”import { installShell } from '@hypermedia-components/core';installShell(); // or the auto-init bundle: @hypermedia-components/core/behaviorsFor a shell that has a [data-hc-shell-toggle] button and a
.hc-shell__sidebar, installShell():
- toggles
data-sidebar="open"on the shell and keepsaria-expanded/aria-controlsin sync; - moves focus into the sidebar on open and traps
Tabwithin 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.
Collapsible sidebar
Section titled “Collapsible sidebar”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).
Accessibility
Section titled “Accessibility”- 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,
Tabis trapped,Escapedismisses, and focus returns to the toggle. - Give the toggle an accessible name (
aria-labelor visible text);installShell()adds a fallback label if neither is present.
CSS variables
Section titled “CSS variables”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.
| Variable | Default | Controls |
|---|---|---|
--hc-shell-sidebar-width | 16rem | Sidebar column width (desktop) / overlay width (mobile, capped at 80vw). |
--hc-shell-sidebar-collapsed-width | 4rem | Sidebar column width when collapsed to a rail (data-sidebar-collapsed). |
--hc-shell-aside-width | 20rem | Aside column width. |
--hc-shell-pad | var(--hc-space-4) | Inner padding of the regions. |
Related
Section titled “Related”- Layout utilities — the primitives the shell is built on.