Nav Bar

@sushindustries/ui

A site header whose panels expand, built on <details> so it works before hydration.

9 min read
View as Markdown

Three sizes, one markup

The wide layout is a row of triggers, each opening a panel anchored under it. Panels are right-aligned rather than centred on their trigger, because centring puts the last entry's panel half off-screen and then it has to be clamped, which means its left edge moves depending on which trigger opened it.

The narrow layout is one burger opening a drawer from the right edge, full height, over a dimmed page. A drawer rather than a dropdown: a dropdown of this many entries covers the page it came from and leaves you unsure whether you navigated. A drawer keeps a strip of the page visible, and that strip is what says you are still on it.

Both are the same markup with different CSS, at one breakpoint, mutually exclusive on either side of it. Two components would be two things to keep in step, and the one only visible on a phone is the one that goes stale.

WidthWhat you get
over 860a row of triggers, panels two or three columns wide
620 to 860the burger, opening a 23rem drawer over a dimmed page
under 620the burger, opening the whole screen

A 23rem drawer on a 390px phone is not a drawer. It is a panel with a 40px strip of unusable page beside it, and that strip is a "close" target nobody can hit. Full width also gives the rows the space they were designed at instead of squeezing them. The tablet keeps the drawer, because there a strip of page is wide enough to still read as page.

The drawer takes 420ms, slower than the desktop panel's 220ms. It travels the width of the screen, and a full-screen surface that arrives in 200ms reads as a page change rather than as something opening.

Four traps, all found the hard way

`backdrop-filter` breaks `position: fixed` beneath it

An element with backdrop-filter becomes the containing block for every fixed-position descendant. With the blur on the header itself, this drawer measured inset: 0 against the header: a full-height drawer came out sixty pixels tall, in the wrong place, with a scrim that dimmed the header and nothing else. The fix is one line - put the blur on a pseudo-element of the header instead. It looks identical and the header stops being a containing block.

[!CAUTION] One blur per surface The icon tiles had backdrop-filter too. Eighteen of them, inside a panel that was blurred, inside a header that was blurred, is three GPU readbacks per frame per layer, and it crashed the renderer. The tiles now get their glass from a gradient, which is what was doing the visual work anyway. The drawer is opaque, because it sits over a 62% scrim and was paying for a full-screen readback to composite something already hidden.

[!CAUTION] A smooth-scroll driver owns the page, including over your overlay Lenis intercepts wheel and touch for the whole document and animates the scroll itself, so a drag inside this drawer moved the article behind it rather than the menu. The drawer carries data-lenis-prevent, which Lenis reads as "leave this subtree to the browser". Anyone not using Lenis gets an inert data attribute. Alongside it, overscroll-behavior: contain stops the scroll chaining at the end of the list, touch-action: pan-y claims vertical drags. What it does not do is lock the page: see below for why that cure was worse than the disease.

[!CAUTION] opacity: 0 takes the pseudo-elements with it The burger is three bars: the element's own background, plus ::before and ::after. Hiding the middle one with opacity: 0 grouped all three, and opacity: 1 on a pseudo-element cannot escape its parent's opacity - so pressing the burger hid the whole icon and the full-screen drawer opened with no visible way out. background: transparent clears only the middle bar, because only the parent's own background draws it.

Closing it

While the drawer is open, the toggle becomes position: fixed above the sheet and the bars cross into an X.

That is deliberate rather than a second close button. The drawer covers the header the toggle lives in, so the toggle has to be lifted out or there is no exit at all - and lifting the same control is better than adding another, because the thing that closes the menu is visibly the thing that opened it, in the same place, having changed shape.

Escape also closes it, for free, because it is a <details>.

Nothing on the page moves as it opens, and getting there meant taking something out rather than adding it.

The obvious way to stop the page moving behind a drawer is html:has(.nav-burger[open]) { overflow: hidden }. It is worse than the problem it solves, twice over. Making the scrolling element non-scrollable drops its scroll offset, so opening the menu halfway down a page throws the reader back to the top and closing it does not bring them back. It also removes the scrollbar, which makes the document about fifteen pixels wider at that instant, so everything centred slides sideways as the menu arrives.

So there is no scroll lock. What actually needed fixing was scroll chaining, and that is handled where it happens: overscroll-behavior and touch-action on the sheet, and data-lenis-prevent so the smooth-scroll driver leaves the drawer alone. The page behind can still be scrolled while it is covered, which is a smaller cost than losing someone's place.

scrollbar-gutter: stable stays on html regardless, because the width of the gutter should not depend on whether a given page happens to be long enough to need it.

Inside the drawer, every row starts with a tile on one column, group headers included. Items sit behind a rule set in by the tile's width rather than indented: indenting would push each item's tile a few pixels right of its group's, and the drawer would read as two columns that nearly line up, which is worse than either lining up or clearly not. Rows are at least 44px tall, and they answer a press with :active, since a touch screen has no hover to give.

Where it is used

Where this is used

WhereWhat it renders
Every page on this sitevia apps/web/src/modules/chrome/site-nav.tsx
apps/web/content/nav.mdthe entries, as a nested Markdown list
packages/ui/glyphs.mdthe icon on each entry and item
packages/ui/registry.tsthe Components panel, expanded from the categories

site-nav.tsx is nine lines and holds one decision: that this particular site has a wordmark and a GitHub link. Everything else moved out to somewhere it can be edited without touching a component.

The Markdown format

- [Components](/components) `layers`
  - {categories}
- [Writing](/posts) `note`

A top-level item is an entry. Indent under one and it becomes a panel; leave it alone and it stays a plain link, which is right for most of them - a menu that opens to reveal one link is more interface than the thing it hides.

The backticked word is a glyph name. pnpm doctor rejects one that is not in the glyph table, so a typo fails a check rather than silently rendering nothing.

{categories} expands from the registry, with a live count per category. Writing the categories out in the nav file would be a second list to keep in step with the first, and the first thing that goes wrong is a menu entry that filters to nothing.

The glass

Each icon sits on a tile with a light top edge fading into a darker fill. That gradient is the whole effect: a lit edge over a solid body is what reads as a raised piece of glass, and it costs one linear-gradient.

It used to have a backdrop-filter as well, which is what crashed the renderer. Removing it changed almost nothing about how the tile looks, which is the useful part of the story - the filter was never doing the work.

Hovering the row lifts the tile with it - the border warms to the accent and so does the glyph - so the whole item reads as one target rather than as a link with a picture next to it.

Motion is optional

Panel and drawer animations, the chevron and the burger morph are all off under prefers-reduced-motion: reduce. Nothing about the menu depends on them; they are the polish, not the mechanism.