Navbar
The site chrome, end to end: anatomy, alignment, the active link, dropdowns, the submenu row, four positions, three ways to open on a phone — and a builder that writes the markup for whichever combination you land on.
The navbar is one component with several shapes: a site bar, a bar with menus, a mobile sheet, and a submenu row for whatever you are inside. They share a skeleton and a height, so moving between them never shifts the chrome. Everything below is on this page because you cannot pick the right shape from a page you did not know existed.
1 · Anatomy
Three slots: the mark on the left, the links in the middle, the actions on the right. .nav-shell positions and centres; .nav-bar is the island itself. The shell is sticky, so the bar is always reachable.
.nav-shell > .nav-bar — mark · links · actions
<header class="nav-shell"> <nav class="nav-bar" aria-label="Main"> <!-- 1 · the mark --> <a class="logo logo-sm" href="/">…</a> <!-- 2 · the links --> <div class="nav-links"> <a class="nav-link" href="/blog/" aria-current="page">Blog</a> <a class="nav-link" href="/videos/">Videos</a> </div> <!-- 3 · the actions --> <div class="cluster-sm">…</div> </nav></header>the whole skeleton — every variant below changes only what it must
| Class | Job |
|---|---|
.nav-shell | sticky wrapper; centres the bar and sets its max width |
.nav-bar | the island: height, border, radius, blur, shadow |
.nav-links | the middle slot; a flex row of .nav-link |
.nav-link | one destination. Add an .icon before the label |
--nav-h | the bar height (3.5rem). Every contextual bar inherits it |
2 · Alignment — pick deliberately
“Same width as the content” means two different things, and picking silently is how a nav ends up looking 16px wrong. The island either shares its border with the content column edge, or shares its text with the page text. The shell already derives its width from --w-site and --gutter, the same two tokens .container uses — so the edges match by construction.
.nav-shell — DEFAULT. The island’s border meets the content edge; it reads as a floating object.
.nav-shell-flush — the island’s CONTENT lines up with the page text, so the wordmark sits directly above the page title.
| Variant | When |
|---|---|
.nav-shell | floating island. Use when the page has a hero or a pattern behind it |
.nav-shell-flush | flat bar, content-aligned. Use for docs and reading-first sites |
.nav-shell-full | edge-to-edge bar with no border at all — the island earns its outline by floating, a bar that fills the width does not. Contents still sit in the site column |
[data-scrolled] | set it from JS past ~8px of scroll; the island tightens and gains a shadow |
3 · The active link
aria-current="page" is the ONLY hook. Styling and the accessibility tree are then the same attribute and cannot disagree — which is exactly why a .active class is banned here.
default — the signal dot. One per bar, and it means “you are here”.
.nav-links-rule — a 2px accent underline. For docs and dense bars where a dot reads as decoration.
.nav-links-soft — a sunken wash, when neither dot nor rule survives the background.
Setting it. In Ghost, the navigation helper already knows:
{{#foreach navigation}} <a class="nav-link" href="{{url}}" {{#if current}}aria-current="page"{{/if}}>{{label}}</a>{{/foreach}}Ghost exposes current inside {{#foreach navigation}} — use it and the dot is automatic
The trap. A hardcoded fallback menu has no current property, so nothing ever receives the attribute and the bar never shows where you are. If you ship a fallback, mark it on load:
// mark the link whose path prefixes the current oneconst here = location.pathname;document.querySelectorAll('.nav-link').forEach(a => { const path = new URL(a.href).pathname; if (path !== '/' && here.startsWith(path)) a.setAttribute('aria-current', 'page');});longest-prefix wins, so /courses/java/lesson-1/ still lights up “Courses”
4 · Dropdown
A native <details>: keyboard, Escape and click-away come from the browser. One level only — a second level is a mega panel, not a fly-out, because fly-outs are unusable on touch.
.nav-menu — click “Learn”. The caret is drawn by CSS from the open state.
<details class="nav-menu"> <summary class="nav-link">Learn</summary> <div class="nav-menu__panel"> <a class="dropdown__item" href="/courses/">Courses</a> <hr class="dropdown__divider" /> </div></details>reuses .dropdown__item / __divider / __head from the Dropdowns component
5 · Mega panel
A full-width sheet under the island: link columns plus one featured cell. A mega menu that is only links is a dropdown wearing a costume — the featured cell is what earns the extra space. Add .nav-mega beside .nav-menu; it goes position: static so the panel can span the whole bar.
.nav-mega__panel — shown open. Columns auto-fit; the feature cell is a link, not a card.
<details class="nav-menu nav-mega"> <summary class="nav-link">Everything</summary> <div class="nav-mega__panel"> <div class="nav-mega__col"> <span class="nav-mega__title">Watch</span> <a class="nav-mega__link" href="/videos/"> <svg class="icon">…</svg> <span><b>Videos</b><span>Tutorials</span></span> </a> </div> <a class="nav-mega__feature" href="…">…</a> </div></details>the .nav-shell must be position: relative for the panel to span it
6 · Hamburger
Three bars that become an X — one element and two pseudo-elements, no icon font and no swapped SVG. State lives in aria-expanded, the same attribute a screen reader announces. Click them.
.nav-burger + -squeeze / -aperture / -bare / -labelled
<button class="nav-burger" type="button" aria-expanded="false" aria-controls="site-menu"> <span class="nav-burger__box"> <span class="nav-burger__bars"></span> </span> <span class="u-sr-only">Menu</span></button>the bars are decorative — the button still needs a name, hence .u-sr-only
| Hook | Why |
|---|---|
aria-expanded | toggle it in JS when the sheet opens/closes; CSS draws the X from it |
aria-controls | the sheet’s id, so the relationship is announced |
.nav-burger-aperture | rotates as one — pair it with the sheet’s iris open |
.nav-burger-bare | no ring, for bars that already have a border |
7 · Mobile sheet
A real <dialog>, so focus trapping and Escape are the platform’s job. It opens like a lens: the panel irises out from the button corner, a scanline sweeps once, and the rows rack into focus in sequence — blur to sharp, staggered. All of it is disabled under prefers-reduced-motion.
dialog.nav-sheet — press the button; Escape closes it
<dialog class="nav-sheet" id="site-menu"> <div class="nav-sheet__in"> <span class="nav-sheet__scan" aria-hidden="true"></span> <div class="nav-sheet__head">… mark + .btn-close …</div> <nav class="nav-sheet__links"> <!-- --i is the stagger index --> <a class="nav-sheet__link" style="--i:0" href="/blog/">Blog</a> </nav> <div class="nav-sheet__foot">…</div> </div></dialog>set --i per row for the stagger; open with .showModal(), never by toggling a class
const sheet = document.getElementById('site-menu');const btn = document.querySelector('.nav-burger');btn.addEventListener('click', () => { sheet.showModal(); btn.setAttribute('aria-expanded', 'true');});sheet.addEventListener('close', () => btn.setAttribute('aria-expanded', 'false'));the close event fires for Escape too, so the burger always returns to bars
8 · Responsive collapse
Put .nav-collapse on the island: the link row hides below 48rem and the burger appears. No duplicate markup, no JS, no breakpoint classes in the template. Narrow this window to watch it swap.
.nav-collapse — links above 48rem, burger below
9 · A style per collection
A creator does not publish one kind of thing, and the bar above a web series should not look like the bar above a shop. Each collection gets a class that sets its defaults — and only defaults. Every one of them is written in the same tokens the rest of the system uses, so changing any part of it is a variable, not a fork.
| Class | What it assumes |
|---|---|
.nav-video | the series — no island at all: the bar sits over the footage, transparent, and becomes furniture once you scroll past it |
.nav-blog | the writing — flat, hairline underneath, no plate; reading wants the chrome to leave |
.nav-course-bar | the syllabus — a soft plate, and room for the read-through line |
.nav-shop | the shop — a squarer plate, actions spaced for a cart |
.nav-trip | the journal — the accent warms the bar itself |
.nav-docs-bar | the reference — 2.75rem, flat, dense |
The knobs every one of them sets, and every one of them yields to: --bar-bg, --bar-fg, --bar-line, --bar-radius, --bar-h, --bar-blur. Set one yourself and the collection style gets out of the way.
.nav-blog — flat and quiet; the page is the point
.nav-shop — a squarer plate; the cart is the one action that matters
.nav-trip — the accent warms the bar; the journal is a warm object
The series is the one that is not an island at all. A series opens on its own footage, so the bar sits over the film and owns nothing until you scroll — the gradient does the legibility work a background would otherwise have to do.
.nav-video in .nav-over — no plate, no border; nav.js swaps it to ink once you scroll past the film
<header class="nav-shell nav-shell-full nav-over"> <nav class="nav-bar nav-video">…</nav></header><div class="nav-over__media"> <video src="…" autoplay muted loop playsinline></video></div>the shell carries the gradient, so white type stays legible on any frame
10 · How the bar behaves on scroll
Four behaviours, none of which change the markup. Put the class on the shell; nav.js sets data-scrolled and data-dir, and every visible decision after that is CSS.
| Class | Behaviour |
|---|---|
.nav-shell | the default — a sticky island that never changes shape |
.nav-shell-fixed | pinned to the top, always there |
.nav-shell-auto | hides on the way down, returns on the way up — reading gets the screen, navigating gets the bar |
.nav-shell-morph | nothing at rest — no surface, no blur, no line, just the links over the page — then the island fades and draws itself in once the page has moved |
.nav-shell-reveal | the same disappearing act as -auto, but sticky rather than fixed — the bar keeps its place in the flow, so nothing hides under it at the top |
-morph moves the island, not its contents. The alignment padding lives on the shell and gives up exactly one gutter on scroll; the bar takes the same gutter back. The sum never changes, so the wordmark and the controls hold one x while the island's edges travel around them — a bar whose logo slides on every scroll is a bar that looks broken.
Pair .nav-shell-full with either hiding behaviour for a plain bar across the page that leaves on the way down. And note that -morph reaches its bar through :is(), so the variant still works once the island grows a second row and the bar sits inside .nav-stack.
scroll this panel ↓
.nav-shell-morph — full width at the top, an island once you move; scroll inside the box
<header class="nav-shell nav-shell-morph" data-scroll-at="24"> <nav class="nav-bar">…</nav></header><!-- nav.js adds data-scrolled past data-scroll-at (default 24px) -->one class, plus nav.js; no inline handlers and no scroll maths in your theme
11 · The line is the progress
The island already has a hairline. Rather than add a second piece of chrome under it, the line itself fills: the read part is the accent, the rest is the accent at a whisper. Set --progress and the border does the rest — it works on the plain bar and on the two-row stack alike.
.nav-progress — --progress:64%; the border is the bar
<header class="nav-shell nav-progress" style="--progress:64%"> <nav class="nav-bar">…</nav></header>the ring is masked to the border, so the radius survives the gradient
12 · Centred, inverse, compact
Three shapes that are token swaps rather than layouts of their own. The inverse bar takes the light mark for free — the logo is markup reading currentColor, so it inverts with the bar and needs no second asset.
.nav-bar-center — the mark takes the middle, links and actions balance it
.nav-bar-inverse — ink whatever the theme; the mark inverts with it
.nav-bar-compact — 2.75rem; for docs and players where the bar is chrome
13 · The burger, as a record light
Three bars becoming an X says nothing about what this site is. Here they collapse into the record light the whole system is built around, and the light unfolds into a play head: closed is ready, open is rolling. Same markup as every other burger — aria-expanded drives it, so the glyph and the accessibility tree can never disagree.
.nav-burger-rec · .nav-burger-aperture · .nav-burger-squeeze · .nav-burger-bare — click each
14 · Dropdowns — hover, click, and a second level
Pointer users open on hover with an intent delay, everyone else on click, and both land on the same <details open> — one open state, not two. Touch is left alone, because there the first tap is the hover and stealing it costs the reader their click. The panel grows to its content rather than jumping to it.
.nav-menu-hover.nav-menu-grow + .nav-sub-menu — hover it, then open “By subject”
| Class | What it adds |
|---|---|
.nav-menu-hover | opens on hover for fine pointers, with a 120ms intent delay |
.nav-menu-grow | the panel animates to its own height (0fr → 1fr), no fixed max-height |
.nav-sub-menu | a second level, disclosed in place — never a fly-out |
15 · Three ways to open on a phone
The full-screen sheet is one answer, not the only one. All three take the same link markup, so choosing between them is a class rather than a rewrite.
| Class | Opens as |
|---|---|
.nav-sheet | full-screen dialog; irises open from the button, scanline sweeps once |
.nav-sheet-drop | full-width panel hinged at the top edge — the page stays visible beneath |
.nav-panel | no dialog at all: the island itself grows into the panel |
.nav-panel — press the burger: the island grows, nothing covers the page
<div class="nav-shell"> <nav class="nav-bar"> … <button class="nav-burger nav-burger-rec" aria-expanded="false" data-panel-toggle>…</button> </nav> <div class="nav-panel"> <div class="nav-panel__in">… links + aside …</div> </div> </div></div>nav.js flips data-open on the stack; Escape closes it and returns focus
data-open lands on the panel's own parent. When the bar is the only row that is the shell; inside a .nav-stack it is the stack. nav.js picks the stack first and the stylesheet answers to both, so a panel works either way — but it must be a direct child of whichever one carries the attribute.
| Class | What it adds |
|---|---|
.nav-burger-pinned | keeps the burger visible at every width inside a .nav-collapse bar — one way into the menu instead of a second desktop-only control |
.nav-panel__links-split | two columns of links above 48rem; group labels keep their own full-width row |
.nav-panel__name | wraps an icon and its label so they travel together — .nav-panel__link is space-between, so an unwrapped label drifts to the far edge |
Cap the panel yourself if the site has a lot of destinations: max-height: calc(100dvh - var(--nav-h) - var(--space-10)) with overflow-y:auto on .nav-panel__in keeps the island inside the viewport instead of running off the bottom of it.
16 · Icons, the call, and the form
A creator's bar carries more than links: where else to find them, the one thing they want you to do, and the list. The newsletter is folded into its own icon — closed it is one more chip, open it is a field that grew out of that chip. It is a <details>, so Escape and focus come free.
.nav-icons · .nav-form · the CTA — press the envelope
17 · Telling machines what the nav is
Markup alone does not say “these five links are the site navigation”. SiteNavigationElement does, and a BreadcrumbList says where the current page sits inside it. Every page of these docs ships both — view source and look for application/ld+json.
<script type="application/ld+json">{ "@context": "https://schema.org", "@type": "ItemList", "itemListElement": [ { "@type": "SiteNavigationElement", "position": 1, "name": "Watch", "url": "/watch/" } ]}</script>one list per nav; the breadcrumb is a second block, not a second list
18 · Rules
Do
- Mark the current page with
aria-current="page". - Keep one dot per bar — it means “you are here”, not “this is nice”.
- Keep the site menu and add a submenu row beneath it — never swap the bar out.
- Use
<details>and<dialog>so keyboard and Escape work for free. - Give every icon-only control an accessible name.
Don’t
- Use an
.activeclass — it can disagree with the a11y tree. - Fill the active link with a coloured pill.
- Nest a second dropdown level; promote it to a mega panel.
- Duplicate the menu markup for mobile —
.nav-collapsealready handles it. - Write a new class per collection; set
data-toneand fill the slots.
19 · Variants — four positions, one markup
Not ten skins. A bar's shape is decided once in the design; where it goes when the reader scrolls is a decision about behaviour, and that is the one worth a variant. Put the class on the shell — the bar never changes, so switching is a class swap and never a restructure.
The skins that used to be variants are the things they always were: .nav-bar-center, .nav-bar-inverse and .nav-bar-compact for shape, .nav-shell-flush and .nav-shell-full for alignment, .nav-ghost for a bar that materialises over a hero. Nothing was lost — the list stopped pretending “bordered” and “island” were the same kind of choice.
| Class | Position |
|---|---|
.nav-shell | Island — The floating pill, sticky from the first pixel. The default. |
.nav-shell-fixed | Fixed — Pinned to the top and never anywhere else. For apps and dashboards, where the chrome is furniture. |
.nav-shell-morph | Island on scroll — Full-bleed at rest, drawing itself into the island once the page has moved. The header a site opens with, and the bar it works with. |
.nav-shell-auto | Fixed on scroll — In flow at the top; pinned once you scroll up, and out of the way while you read down. Reading gets the screen, navigating gets the bar. |
Island — The floating pill, sticky from the first pixel. The default.
Fixed — Pinned to the top and never anywhere else. For apps and dashboards, where the chrome is furniture.
Island on scroll — Full-bleed at rest, drawing itself into the island once the page has moved. The header a site opens with, and the bar it works with.
Fixed on scroll — In flow at the top; pinned once you scroll up, and out of the way while you read down. Reading gets the screen, navigating gets the bar.
20 · Choosing one in a Ghost theme
Two levels, and the more specific one wins: a site-wide default from a theme setting, overridden per post or page by an internal tag.
| Level | How |
|---|---|
Site default | Ghost Admin → Design → Navbar style (a select in package.json) |
Per-page override | tag the post #navbar-ghost, #navbar-inverse, … — internal tags never show to readers |
Why the tag wins | a landing page that opens on a full-bleed hero genuinely knows better than the site default |
<!-- partials/navbar.hbs -->{{#has tag="#navbar-ghost"}}{{> nav/bar style="ghost"}}{{else}}{{> nav/bar style=@custom.navbar_style}}{{/has}}the tag override first, the theme setting as the fallback
21 · Navbar builder
Every knob on this page, in one place. Start from a preset — a web series, a docs site, a course — then change anything: the position, the accent, what the main row carries, what the submenu says. The markup underneath rewrites itself as you go, so what you copy is what you are looking at.