App shell
A page layout for apps with a sticky header, a sidebar that becomes a drawer on small screens, a main area and a footer.
On this page
The app shell lays out a whole page. Put data-app-shell on body and give it a skip link, a header, an aside, a main and a footer, in that order. Below 1024px the sidebar is a drawer, a non-modal popover opened by a button in the header. From 1024px it is a column of its own, next to the main area.
Because the hook is on body, a live frame cannot show it. This is the dashboard example, built on the app shell:
Load css/, or the whole set from css/pico.extras.min.css. See the overview.
Markup
<body data-app-shell>
<a href="#main">Skip to content</a>
<header>
<button type="button" popovertarget="app-nav">
<svg aria-hidden="true" focusable="false" width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
<span>Menu</span>
</button>
<strong>Acme Cloud</strong>
<nav aria-label="Account">
<ul>
<li><a href="#account">Account</a></li>
<li><a href="#signout">Sign out</a></li>
</ul>
</nav>
</header>
<aside id="app-nav" popover aria-label="Sidebar">
<nav aria-label="Main">
<small>Workspace</small>
<ul>
<li><a href="#home" aria-current="page">Overview</a></li>
<li><a href="#projects">Projects</a></li>
<li><a href="#inbox">Inbox<small>12</small></a></li>
</ul>
<small>Manage</small>
<ul>
<li><a href="#team">Team</a></li>
<li>
<details open>
<summary>Settings</summary>
<ul>
<li><a href="#general">General</a></li>
<li><a href="#billing">Billing</a></li>
</ul>
</details>
</li>
</ul>
</nav>
</aside>
<main id="main" tabindex="-1">
<header>
<hgroup>
<h1>Overview</h1>
<p>Usage for the last 7 days across all projects.</p>
</hgroup>
<button type="button">New project</button>
</header>
<section aria-labelledby="stats">
<h2 id="stats">This week</h2>
<article>
<p><small>Active users</small></p>
<strong>2,481</strong>
</article>
<article>
<p><small>Deployments</small></p>
<strong>96</strong>
</article>
</section>
<section aria-labelledby="recent">
<h2 id="recent">Recent deployments</h2>
<figure role="region" aria-label="Deployments table" tabindex="0">
<table>
<thead>
<tr><th scope="col">Project</th><th scope="col">Status</th></tr>
</thead>
<tbody>
<tr><th scope="row">billing-api</th><td>Ready</td></tr>
<tr><th scope="row">web-app</th><td>Building</td></tr>
</tbody>
</table>
</figure>
</section>
</main>
<footer>
<small>Acme Cloud v2.3.0</small>
</footer>
</body>The rules the markup follows:
- The hook is
data-app-shellonbody. No class is needed, so it works in the classless build. - The children of
bodycome in this order: skip link to#main,header,aside,main,footer. - In the header, the toggle button needs only
popovertarget. The browser exposes the expanded state, so do not add a staticaria-expanded. The toggle is shown only below 1024px, and below 576px only its icon shows (the label stays for assistive technology). - The
asideis apopoverwith anaria-label. Inside it, anav aria-label="Main". Asmallbefore aulis a group label,aria-current="page"marks the current link, ansvgwitharia-hidden="true"before the link text is the icon, asmallinside a link is a count, and adetailsinside anliis a nested group. mainhasid="main"andtabindex="-1", so the skip link can land on it. Aheaderdirectly inmainis the page title row. Asectionthat holdsarticleelements lays them out in a responsive grid.- A
figurearound a table scrolls sideways on narrow screens. Give itrole="region", anaria-labelandtabindex="0". - The header is at least
--pico-app-shell-header-heighttall. A long brand is cut with an ellipsis and account links wrap, so nothing scrolls sideways at 320px.
Pages that contain the shell get scroll-padding-block-start, so anchor links and focus jumps land below the sticky header.
JavaScript
The shell works without script: the toggle opens and closes the drawer, Escape closes it, and focus returns to the toggle. A few lines make it better. This one moves focus into the drawer when it opens, and closes it when you tap the dimmed scrim (a tap on the scrim lands on the aside itself, which light dismiss ignores).
const drawer = document.querySelector("body[data-app-shell] > aside[popover]");
drawer.addEventListener("toggle", (e) => {
if (e.newState === "open") drawer.querySelector("a")?.focus();
});
drawer.addEventListener("click", (e) => {
if (e.target === drawer) drawer.hidePopover();
});In a single page app, close the drawer after in-app navigation:
drawer.addEventListener("click", (e) => {
if (e.target.closest("a[href]")) drawer.hidePopover();
});If the window grows past 1024px while the drawer is open, the drawer stays pinned over the sidebar column. Close it when that happens:
matchMedia("(width >= 1024px)").addEventListener("change", (e) => {
if (e.matches) drawer.hidePopover();
});Known gaps
- The drawer is non-modal. Tab leaves it into the page without closing it.
- The scrim tap needs the script above.
- A header that wraps to several lines is not tracked by the sticky offsets. Set
--pico-app-shell-header-heightto the real height. - Where
popoveris not supported, the sidebar stays in the page flow and the toggle is hidden.
Accessibility
- The skip link is off screen until it has focus.
- Each
navhas its ownaria-label("Account", "Main"), so landmarks are told apart. - The drawer follows the popover rules: Escape closes it and focus returns to the button that opened it.
Customization
--pico-app-shell-nav-width- Width of the sidebar column from 1024px.
--pico-app-shell-header-height- Minimum header height, also used for the scroll offset.
--pico-app-shell-background-color- Fill of the skip link.
--pico-app-shell-border-color- Lines between header, sidebar and main.
--pico-app-shell-nav-background-color- Sidebar background.
--pico-app-shell-nav-muted-color- Color of group labels and idle sidebar text.
--pico-app-shell-nav-hover-background-color- Sidebar link on hover.
--pico-app-shell-nav-current-background-color- Background of the current link.
--pico-app-shell-nav-current-color- Text color of the current link.
--pico-app-shell-scrim-color- Dim layer behind the drawer.