DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our header is 48 pixels tall, every page offsets by 56, and the root layout renders no header at all

notifio.app is the marketing site for a desktop app that watches rental search pages and alerts you when a new listing appears. The site is 38 URLs. Its header is one component, 70 lines, and the three most interesting things in it are a pair of pointer-events classes, a number that is wrong on purpose, and the fact that app/layout.tsx does not render it.

The layout renders fonts and a JSON-LD block, and then stops

Here is everything inside <body> in the root layout:

<body className="bg-[#0a0a0a] text-[#fafafa] antialiased font-sans">
  {/* Who is behind this site, in the form a crawler reads. */}
  <script type="application/ld+json" dangerouslySetInnerHTML={{ ... }} />
  {children}
</body>
Enter fullscreen mode Exit fullscreen mode

No nav. No footer. Both are imported and rendered by each page:

$ grep -rl --include="*.tsx" "<Nav" src | wc -l
15
Enter fullscreen mode Exit fullscreen mode

Fifteen files, each opening with the same three lines. The App Router's whole pitch is that shared chrome belongs in a layout, so this needs a reason, and the honest one is a shape rather than a rule: a layout wraps every route under it, and opting out of a layout means moving a route into a different segment. Putting the header in the page means a page that should not have one simply does not import it.

Today, all fifteen import it. The payment success page imports it, the maintenance page imports it, and so the opt-out I am describing is currently used by nothing, which makes the duplication a cost with a hypothetical benefit. The part that keeps it from being a real problem is that fifteen files is not 38 pages: one of them is ArticleLayout, which renders all eight long-form pages, the five listed at notifio.app/guides plus three audience pages, and two more are dynamic routes covering the fifteen entries at notifio.app/alerts and the five at notifio.app/compare. The number of files that would need editing to change the chrome is fifteen; the number of pages is 38.

If I were starting again I would put it in the layout and use a route group for anything that needs to escape. I am writing down the trade rather than the conclusion, because the version in the repo works and has never once been the reason a change was hard.

Two classes so a transparent header does not eat clicks

The header is fixed, full width, and centres its contents:

<div
  className={`fixed top-0 inset-x-0 z-50 flex justify-center pointer-events-none transition-all duration-300 ease-out ${
    scrolled ? "pt-3 px-4" : "pt-0 px-0"
  }`}
>
  <nav className="pointer-events-auto w-full ...">
Enter fullscreen mode Exit fullscreen mode

pointer-events-none on the outer div, pointer-events-auto on the nav inside it. This is the entire reason the design works.

Once scrolled, the nav is a rounded pill with max-w-6xl and 12px of padding above it. The outer div is still the full width of the viewport and roughly 60 pixels tall, sitting at z-50 above everything. Without those two classes, that invisible rectangle swallows every click and hover in the top strip of the page, including the ones aimed at the gap either side of the pill. Nothing in the UI tells you why. You just find a dead zone at the top of a page, usually on a wide monitor, usually weeks later.

The pair is the smallest fix available: the container keeps its layout job and gives up its hit-testing job, and the one child that needs to be clickable takes hit-testing back. Worth remembering as a shape, because every floating or inset fixed element has this problem: a fixed positioning container is as clickable as it is wide, not as wide as it looks.

The scroll state is a boolean, and that is a throttle

const [scrolled, setScrolled] = useState(false);

useEffect(() => {
  const onScroll = () => setScrolled(window.scrollY > 20);
  window.addEventListener("scroll", onScroll, { passive: true });
  return () => window.removeEventListener("scroll", onScroll);
}, []);
Enter fullscreen mode Exit fullscreen mode

No requestAnimationFrame, no debounce, no IntersectionObserver sentinel. The listener runs on every scroll event, which during a trackpad flick is a lot of events, and it reads window.scrollY each time, which is a layout read.

What it does not do is re-render. setScrolled(false) while the state is already false is a no-op as far as the rendered output goes, because React bails out when the next state is identical to the current one. The component re-renders when the value flips, so a long scroll down the pricing page produces one state change rather than four hundred. The derived class strings change at exactly the moment the appearance is supposed to change, and the CSS transition does the 300ms of work between the two states.

That leaves the cost of the handler itself, which is why { passive: true } is there: it promises the browser that this listener will never call preventDefault, so scrolling is not blocked waiting to find out. A boolean threshold plus a passive listener is enough for this. The version with a rAF-throttled handler and a ref holding the last value is more code that produces the same pixels.

The one thing I would change: the handler never runs on mount. Load the page already scrolled, which happens on a refresh or when following a link to an anchor, and the header renders in its unscrolled state until you move. One extra onScroll() call before the addEventListener fixes it, and it is a genuine bug rather than a trade.

The number 56, seventeen times

The nav's inner row is h-12, which is 48 pixels. When scrolled it gains pt-3, 12 more, for 60. Every page then pushes its content down by this:

<main className="pt-14">
Enter fullscreen mode Exit fullscreen mode

pt-14 is 56 pixels. Not 48, not 60, in between.

$ grep -rn --include="*.tsx" "pt-14" src | wc -l
16
$ grep -rn --include="*.tsx" "100vh-56" src | wc -l
2
Enter fullscreen mode Exit fullscreen mode

Fifteen of those sixteen pt-14 matches are a page clearing the header, the sixteenth is the footer's own padding, and the two calc matches are this, on the success and maintenance pages:

<main className="pt-14">
  <section className="flex min-h-[calc(100vh-56px)] items-center justify-center px-6 py-20">
Enter fullscreen mode Exit fullscreen mode

The same constant written twice in two notations, in two files, for pages whose single card is supposed to be vertically centred in whatever is left under the header. Seventeen places that know how tall the header is, and the one match that is not about the header at all is the footer, which is its own small hazard: the string is not searchable for the thing it means.

The defensible part is that the header is position: fixed, so it is out of flow and the gap under it has to come from somewhere. The indefensible part is the value. 48 and 60 are both real heights depending on scroll position, 56 is neither, and the reason it works is that pages have 80 pixels of their own top padding under it so an 8 pixel error is invisible.

A CSS variable, --nav-height, set once and used by both the component and the pages, would make the relationship explicit and let the two states disagree honestly. The reason it has not happened is the reason most of these do not: nothing has gone wrong yet. Writing it down is the cheap half of fixing it.

Three links out of 38 URLs

<Link href="/alerts">Sites</Link>
<Link href="/pricing">Pricing</Link>
<Link href="/download">Get Notifio</Link>
Enter fullscreen mode Exit fullscreen mode

That is the whole menu, with no mobile variant because three short links fit. The hub pages for guides and comparisons are not in it, which is deliberate: the header carries the path a visitor is on, which is "what does it watch", "what does it cost", "give me the app". Everything else is reachable from the footer, which has its own set of trade-offs that I wrote up in the footer post, and from in-page links.

The one detail in the markup worth copying:

<Image src="/icon-light.svg" alt="" width={18} height={18} priority className="opacity-90" />
Notifio
Enter fullscreen mode Exit fullscreen mode

alt="" on the logo, because the word "Notifio" is right there in the same link as text. An alt="Notifio" here makes a screen reader announce the brand twice for one link. The priority is because this is the only image above the fold on every route.

What the component is, in total

A fixed container that does not take clicks, a pill inside it that does, one boolean of state, three links, and a hardcoded offset repeated in every page. The interesting work in a site header is almost never the markup. It is the two classes that keep an invisible rectangle from stealing input, and the constant that quietly knows how tall everything is.

The 38 URLs it sits on are mostly generated rather than written, which is a different post: our sitemap is 38 URLs and only ten of them are typed out by hand. The app the header is advertising is at notifio.app/download.

Top comments (0)