Theme

The docs shell

How these pages navigate. The rail on the left swaps the content beside it instead of reloading the document — the theme’s first HTMX surface, and the pattern to copy when a second one earns it.

The problem

a fixed rail, rebuilt at the top

The rail is a long position: fixed column with its own scrollbar. Every full-page navigation rebuilt it from scratch — click Toggle deep in the Fields group and the new page’s rail arrived scrolled to the top, having forgotten where you were.

The usual fixes restore state: stash the scroll offset, replay it on load. This shell does the other thing — it stops destroying the state. A navigation that never touches the rail has nothing to restore.


Boost, scoped to the rail

hx-boost · hx-select · hx-push-url

The rail’s links carry htmx attributes; the pages they point at are the static build’s ordinary HTML. hx-select carves the content column out of the full response, so the build is the API — no endpoint was added, no fragment is served, and the same URL works with the script, without it, and in a search result.

<div id="railNav" class="docs-rail"
     hx-boost="true" hx-target="#docsMain" hx-select="#docsMain"
     hx-select-oob="#railNav" hx-ext="head-support"
     hx-swap="outerHTML show:window:top" hx-push-url="true">

Boost stays on the rail, deliberately. Content links may point anywhere — another section, an example site, off-site — and swapping an arbitrary page into a docs shell would be worse than a reload. The rail only ever points at pages that share this shell, which is what makes the swap safe to promise.


What travels with a swap

server truth over client re-derivation

A partial swap leaves everything outside the fragment one page stale. Each of those loose ends is owned by the mechanism that cannot get it wrong, rather than by a script that re-derives it:

  • The rail’s active linkhx-select-oob="#railNav" takes the response’s own rail, active state already rendered, and swaps it in alongside the content. The scroll still survives, because scrolling belongs to #docsRail, the container, and the out-of-band swap replaces only its child. The swapped-in rail re-arms its own boost; htmx processes what it inserts.
  • The titlehx-select discards the response’s <head>, so the head-support extension merges it back on every hop. (Use the htmx-ext-head-support package: the copy inside htmx.org/dist/ext is the htmx 1 build and warns under 2.)
  • The Jump-to column’s width — once a class the template computed and the swap could strand, now .docs-shell:has(.sidenav-end): the stylesheet asks the DOM whether a Jump-to rail is actually present. Derived, not declared — there is nothing left to go stale.

What stays scripted

island-docs-nav.js — the residue

The island keeps only what no swap can know. On any arrival — deep link, search hit, a plain navigation — it centres the rail’s active link, because there was no swap to preserve anything. And on a phone, where the rail is a collapse over the content, it closes the menu once a choice is made. Everything else it once handled is listed in its header comment with a forwarding address.


The discipline

when to reach for this, and when to stop

This pattern earns its place when three things line up: the swapped region is expensive state (a scrolled rail), every target shares the shell, and the fallback is the same link working normally. The fields section of the CMS docs is exactly that shape; most of the site is not, which is why nothing else is boosted.

And a tripwire for later: every loose end above was one piece of page state that full reloads kept consistent for free. Three moved to declarative homes; one stayed as script. The day a surface needs a growing list of after-swap handlers is the day to stop patching and morph the whole document instead — or to admit the reload was fine.