The complaint
Two things were wrong with navigation on this site, and they turned out to be the same thing.
The header disappeared when you scrolled down and only came back once you had scrolled up past a full viewport height. Three screens into a project page, the only route back to navigation was a long scroll to the top. And on the book pages, a second bar carrying ← Contents and Actions sat four rems from the top of the window with nothing above it, because it was still anchored below a header that was no longer there.
The wrong question
The original rule was one line:
const hideNav = window.scrollY > window.innerHeight;
That is a position test. It answers am I far down the page? The reader's actual question is can I get to navigation from here?, and those two only agree at the top of a document. Everywhere else the position test locks navigation behind a scroll the reader did not ask to make.
The fix is to ask about direction instead:
const hideBar = direction === "down" && !atTop && !isMenuOpen;
Now the header is one upward flick away at any depth. This is the behaviour every reading site converged on, and it converged there because the position test fails the same way for everyone.
Two details that decide whether it feels right
A threshold, or it strobes. A trackpad reports sub-pixel movement in both directions while a finger merely rests on it. Flip state on every sign change and the header flickers on and off while the reader is holding still. Direction only changes after eight pixels of travel the same way.
requestAnimationFrame, or it thrashes. The scroll event fires far more often than the screen repaints. Reading scrollY inside requestAnimationFrame coalesces a burst of events into one measurement per frame, and doing the read at that point keeps it out of the browser's layout phase.
Neither is visible when it works. Both are extremely visible when they are missing.
The gap was a consequence, not a separate bug
The second bar was stuck at top: 4rem because that is where the site header ends. When the header slid away, the bar kept its offset and floated over a strip of blank page.
There were two ways to close it. Hide the second bar in lockstep with the header, which is less code. Or re-anchor it: let it take top: 0 when the header leaves, and drop back to 4rem when the header returns.
Lockstep is wrong here, and the reason is worth stating plainly. That bar is a table of contents. A reader scrolling down a long project page is the reader who most wants it. Hiding it in lockstep removes the table of contents at the exact moment it is worth the most, in exchange for slightly simpler CSS.
.book-bar {
top: 4rem;
transition: top 200ms ease;
}
.book-bar.is-raised {
top: 0;
}
@media (prefers-reduced-motion: reduce) {
.book-bar { transition: none; }
}
The transition matters. Without it the bar teleports the instant the header leaves, which reads as a glitch rather than a response.
One menu, two triggers
The remaining problem was reach. There is a floating action pill at the bottom of the screen, where a thumb naturally rests. It is the obvious place to open navigation from deep in a page. It could not, because the menu's open state was useState inside the header component, and nothing outside that file could touch it.
The tempting fix is to give the pill its own menu. That is a second component to style, a second one to test, and a second one to remember every time a navigation item is added. Two menus that can disagree about whether they are open.
The state moved into context instead:
interface NavMenuValue {
isMenuOpen: boolean;
open: () => void;
close: () => void;
toggle: () => void;
}
Four members. Anything richer belongs to whoever renders the menu, not to the state that says whether it is showing. The header and the pill now drive the same modal, so the reader gets one menu answering to two triggers rather than two menus drifting apart.
One detail: useNavMenu returns a no-op shape when there is no provider above it, rather than throwing. A component can then be rendered in isolation, in a test or a preview, and simply not have a menu to open.
What the tooling got wrong
My first verification said the re-anchor never fired. It used window.scrollTo to jump three screens down, checked the class, and found nothing.
scrollTo moves in one step. The hook reads incremental deltas from scroll events, so a single jump produces no direction to detect. The test was wrong, not the code. Driving real wheel gestures showed it working immediately: down raised the bar, up dropped it back.
Worth remembering when a scroll behaviour appears not to work under test. Synthetic scrolling and human scrolling are not the same input, and a hook that reads gestures will tell you so.
What I did not fix
While measuring, I found that the book bar's containing block is shorter than the page it sits on. A sticky element cannot stick beyond its containing block, so near the bottom of a long spread the bar leaves the screen regardless of any of this. It predates the change and it is logged rather than quietly patched, because a fix bundled into unrelated work is a fix nobody reviews.
The through-line
Every decision here came from the same question: what does the reader want to do next, and how many gestures away is it? Direction instead of position, because can I reach navigation is not how far have I scrolled. Re-anchor instead of lockstep, because contents matter most while reading. One shared menu instead of two, because a thumb at the bottom of the screen and a cursor at the top want the same thing.
None of it is difficult. All of it is the difference between navigation that answers and navigation that makes you go and get it.