App shell
A fixed topbar and a sidebar that collapses to an icon rail on desktop and becomes an off-canvas drawer on mobile. This page is one — the chrome around you is the component.
Structure
The topbar and sidebar are position: fixed, and _78-app-main makes its own
room for both — so page content never has to be re-wrapped. Nothing here
styles <body>, so the shell drops into an existing page.
<header class="_78-topbar">
<button class="_78-nav-toggle" aria-label="Toggle navigation">…</button>
<a class="_78-topbar-brand" href="/">Your app</a>
<span class="_78-topbar-sub">Dashboard</span>
<span class="_78-topbar-spacer"></span>
<div class="_78-topbar-actions"> … </div>
</header>
<aside class="_78-sidebar" aria-label="Primary">
<nav class="_78-nav">
<div class="_78-nav-heading">Overview</div>
<a class="_78-nav-item _78-active" href="/" aria-current="page">
<span class="_78-nav-icon"><svg …></svg></span>
<span class="_78-nav-label">Dashboard</span>
<span class="_78-nav-badge">12</span>
</a>
</nav>
<span class="_78-nav-spacer"></span>
<button class="_78-nav-item _78-nav-collapse" aria-label="Collapse sidebar">…</button>
</aside>
<div class="_78-scrim"></div>
<main class="_78-app-main"><div class="_78-app-body"> … </div></main>
Topbar
| Class | What it is |
|---|---|
_78-topbar | The fixed bar. Height is --header-h. |
_78-topbar-brand | Product name and mark. Takes the accent on hover; sizes an inline <svg> to 20px. |
_78-topbar-sub | A dim subtitle beside the brand — current page, greeting, breadcrumb. Hidden below 768px. |
_78-topbar-spacer | Flexible gap. Everything after it is pushed right. |
_78-topbar-actions | The right-hand cluster: toggles, menus, avatar. |
_78-nav-toggle | The hamburger. One button, two jobs — see below. |
_78-avatar | A round initial or image, for an account button. |
Sidebar
| Class | What it is |
|---|---|
_78-sidebar | The fixed panel. --sidebar-w wide, --rail-w when railed. |
_78-nav | Optional wrapper. Items work as direct children too. It is a column flex box that fills the sidebar's height — see below. |
_78-nav-heading | A section label. This is how you group — there are no sub-menus. |
_78-nav-item | One entry. Use <a> to navigate, <button> to act. |
_78-nav-icon | Icon slot, 18px. Required for the rail to be usable. |
_78-nav-label | The text. Visually hidden in the rail, but still the item's accessible name. |
_78-nav-badge | A count on the right of an item. Recolors on the active item. |
_78-nav-spacer | Pushes what follows to the bottom of the sidebar. |
_78-nav-collapse | On a _78-nav-item — the rail toggle. Its icon rotates; hidden on mobile. |
_78-active | The current item. Pair it with aria-current="page". |
_78-scrim | The drawer's backdrop. Not rendered at all on desktop. |
Pushing something to the bottom
_78-nav-spacer is flex: 1: whatever follows it is pushed to the bottom of
the sidebar. That is how the collapse control gets there, and it is where a footer item — a
help link, a version string, an account row — belongs.
Since 0.2.0, _78-nav itself grows to fill the sidebar
(flex: 1 1 auto), so the spacer works inside the optional
<nav> wrapper as well as outside it. Before that, a spacer inside the wrapper had
nothing to push against and the item stayed put — if you worked around it by moving the item
out of the <nav>, both arrangements now behave the same, so there is nothing to
undo. This site's own sidebar is the second shape: the spacer and the collapse button are inside
the <nav>.
<aside class="_78-sidebar" aria-label="Primary">
<nav class="_78-nav">
<a class="_78-nav-item" href="/"> … </a>
<a class="_78-nav-item" href="/orders.php"> … </a>
<!-- inside the wrapper: works because ._78-nav fills the height -->
<span class="_78-nav-spacer"></span>
<button class="_78-nav-item _78-nav-collapse" aria-label="Collapse sidebar"> … </button>
</nav>
</aside>
State, and the pre-paint snippet
Shell state lives on <html> exactly like the theme, so it can be restored
before the first paint — otherwise a railed sidebar snaps open for a frame on
every navigation.
| Attribute | Values | Notes |
|---|---|---|
data-sidebar | full · rail | Desktop width. Persisted in localStorage['_78-sidebar']. |
data-drawer | open | The mobile drawer. Runtime only, never persisted. |
[data-sidebar] existing at all. A page with
a sidebar must emit the snippet below; a topbar-only page must not, or it will
reserve room for a sidebar that is not there.
Put this next to the theme snippet, before any stylesheet. It is the same snippet the kit exposes as
_78.shell.PREPAINT, so you can also inject it from a template rather than pasting it.
<script>
(function(){try{var s=localStorage.getItem('_78-sidebar');
document.documentElement.dataset.sidebar=s==='rail'?'rail':'full';}
catch(e){document.documentElement.dataset.sidebar='full';}})();
</script>
_78.shell also sets the attribute on load as a fallback, so the shell still works
without the snippet — at the cost of one frame of the wrong width. See
Getting started for the theme snippet that
goes beside it.
One button, two jobs
_78-nav-toggle collapses the sidebar to an icon rail on desktop and opens the drawer
below 768px — which is what users expect from a hamburger in each context. That is
toggleNav(), and it is wired automatically.
In the rail, labels are visually hidden rather than removed, so they remain each item's accessible
name, and they reappear as a hover or focus tooltip. aria-expanded,
aria-current and focus return on close are handled for you.
The mobile drawer keeps focus inside it while it is open: Tab past the
last item wraps to the first, Shift+Tab from the first wraps to the last, and
Escape closes it and puts focus back on the hamburger. The page behind is made
inert, so a screen reader's browse mode can't wander into it either. The topbar and the
scrim stay live, because the hamburger and a click on the scrim are both ways out.
New in 0.3.0
Try it: the hamburger at the top left of this page rails the sidebar. Narrow the window below 768px and the same button opens the drawer instead.
JavaScript API
// State — getters, not functions
_78.shell.rail; // true when collapsed to the rail
_78.shell.drawer; // true when the mobile drawer is open
// Rail
_78.shell.setRail(true); // persists to localStorage
_78.shell.toggleRail();
// Drawer
_78.shell.openDrawer();
_78.shell.closeDrawer();
_78.shell.toggleDrawer();
// The hamburger's behavior: rail on desktop, drawer on mobile
_78.shell.toggleNav();
// Mark the current page in the nav
_78.shell.setActive('/orders.php'); // by href
_78.shell.setActive('#reports'); // by href or selector
_78.shell.setActive(document.querySelector(…)); // by element
// Constants
_78.shell.KEY; // '_78-sidebar' — the localStorage key
_78.shell.MOBILE; // 768 — the breakpoint, matching the CSS
_78.shell.PREPAINT; // the snippet above, as a string
| Member | What it does |
|---|---|
rail · drawer | Getters for the current state. |
setRail(bool) | Set the rail and persist it. |
toggleRail() | Flip the rail. |
openDrawer() · closeDrawer() · toggleDrawer() | The mobile drawer. |
toggleNav() | Rail on desktop, drawer on mobile. What the hamburger calls. |
setActive(x) | Mark an item current. Takes an element, an href, or a selector; falls back to matching the last path segment. |
mountMenu() · mountMenus() · closeMenus() | The dropdown menu — see Menu. |
Events
Width changes fire _78:railchange and the drawer fires _78:drawerchange,
both on document. These are the hooks for anything that has to re-measure when the
available width changes — a data grid, a calendar, a canvas chart.
document.addEventListener('_78:railchange', function (e) {
// e.detail = { rail: true | false }
calendar.updateSize();
table.redraw();
});
document.addEventListener('_78:drawerchange', function (e) {
// e.detail = { open: true | false } ← note: `open`, not `drawer`
});
The Adapters page does exactly this: its calendar
calls updateSize() on both events, because collapsing the sidebar changes the width and
FullCalendar will not notice on its own.