78 UI Kit
Components

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.

the app shell
<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

ClassWhat it is
_78-topbarThe fixed bar. Height is --header-h.
_78-topbar-brandProduct name and mark. Takes the accent on hover; sizes an inline <svg> to 20px.
_78-topbar-subA dim subtitle beside the brand — current page, greeting, breadcrumb. Hidden below 768px.
_78-topbar-spacerFlexible gap. Everything after it is pushed right.
_78-topbar-actionsThe right-hand cluster: toggles, menus, avatar.
_78-nav-toggleThe hamburger. One button, two jobs — see below.
_78-avatarA round initial or image, for an account button.

Sidebar

ClassWhat it is
_78-sidebarThe fixed panel. --sidebar-w wide, --rail-w when railed.
_78-navOptional wrapper. Items work as direct children too. It is a column flex box that fills the sidebar's height — see below.
_78-nav-headingA section label. This is how you group — there are no sub-menus.
_78-nav-itemOne entry. Use <a> to navigate, <button> to act.
_78-nav-iconIcon slot, 18px. Required for the rail to be usable.
_78-nav-labelThe text. Visually hidden in the rail, but still the item's accessible name.
_78-nav-badgeA count on the right of an item. Recolors on the active item.
_78-nav-spacerPushes what follows to the bottom of the sidebar.
_78-nav-collapseOn a _78-nav-item — the rail toggle. Its icon rotates; hidden on mobile.
_78-activeThe current item. Pair it with aria-current="page".
_78-scrimThe drawer's backdrop. Not rendered at all on desktop.
Flat items only, by design
Section headings group items, and there are no nested or expandable sub-menus. If a section needs sub-pages, they belong in the page rather than in the nav — which is what the “On this page” lists in these docs do.

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

a bottom-anchored item
<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.

AttributeValuesNotes
data-sidebarfull · railDesktop width. Persisted in localStorage['_78-sidebar'].
data-draweropenThe mobile drawer. Runtime only, never persisted.
The attribute's presence is what reserves the space
The sidebar offset is driven by [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.

the sidebar pre-paint snippet
<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

_78.shell
// 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
MemberWhat it does
rail · drawerGetters 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.

shell events
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.