Fixed header overlapping content? Measure its real height
Fixed header overlapping content on phones? Guessed padding hid page titles on a Next.js site. One measured CSS variable fixed every page at once.
By Hamza Ghouri
A fixed header overlapping content is one of those bugs that looks fine on the screen you built it on and breaks everywhere else. The usual cause is that each page guesses how tall the header is with a padding class, and the guess is wrong at some screen width. This post covers how that happened on Fotobank, a stock photo site, and the fix I'd use on any site with a fixed header: measure the header once, store its height in a CSS variable, and let the layout reserve that space for every page.
Why guessed padding left a fixed header overlapping content
Fotobank's header is fixed to the top of the viewport. It holds the logo, navigation, cart and a search box. On desktop it fits on one row. On phones the search drops to a second row, and the header grows to 121 px tall. It also wrapped at 1024 px, a width where the content just didn't fit on one line.
The pages under it didn't know any of that. Each one pushed its content down with a utility class picked by eye: pt-20 on one, mt-20 on another, mt-15 on a third. In Tailwind, pt-20 is 80 px and mt-15 is 60 px. Against a 121 px header, those values leave about 40 to 60 px of content tucked underneath.
The result was the classic symptom. Checkout and Your Purchases lost their page titles on phones: the heading was there in the DOM, hidden behind the header. The Stock Photography page lost its breadcrumb the same way. Nothing errored, and on a wide desktop screen every page looked right, which is how a bug like this survives.
Why per-page offsets always drift
Guessed offsets fail for structural reasons, so fixing the numbers one page at a time only buys time.
- The header's height is not a constant. It depends on the viewport width, which row the search lands on, the fonts once they load, and what the header shows for a signed-in user.
- Every page is a separate guess. Three classes meant three different wrong answers, and each new page added another chance to get it wrong.
- Nobody owns the number. When someone changes the header, nothing tells them which pages assumed its old height.
The fix is to have one owner for the header's height and one place that reserves the space.
Measure the header into a CSS variable
The navbar measures its own height and writes it to a --header-h CSS variable on the root element. A ResizeObserver keeps it up to date, so when the header wraps, a font loads or the window is resized, the variable follows. This is the pattern I'd use in any React app:
const headerRef = useRef<HTMLElement>(null);
useEffect(() => {
const el = headerRef.current;
if (!el) return;
const ro = new ResizeObserver(() => {
const h = Math.round(el.getBoundingClientRect().height);
document.documentElement.style.setProperty("--header-h", `${h}px`);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
Attach headerRef to the header element and the variable stays correct from then on. The observer fires once when it starts observing, so you don't need a separate initial measurement.
Reserve the space once, in the layout
With the variable in place, the per-page guesses go. On Fotobank the layout reserves the header's real height once, for every page, and the pt-20, mt-20 and mt-15 classes are gone. Pages start at the top of their own content and no longer know the header exists.
:root {
--header-h: 121px; /* phone default, so the first paint doesn't jump */
}
main {
padding-top: var(--header-h);
}
html {
scroll-padding-top: var(--header-h);
}
The default value in :root matters. The measurement runs in an effect after the page has rendered, so the CSS needs a sensible starting value or the content would jump on load. On Fotobank the default is 121 px, the phone height, with a media query setting the one-row height for wider screens. Because the measured value is written to the root element's style after hydration, it doesn't cause a hydration mismatch either. I wrote about those separately in fixing React hydration errors from dates and modals.
The scroll-padding-top line is a small extra I'd add on any site with a fixed header. Without it, anchor links and "skip to content" jumps scroll the target to the very top of the viewport, right under the header. scroll-padding-top offsets the scroll position by the header's height, and since it reads the same variable, it stays correct too. Chris Coyier covers the same problem on CSS-Tricks in fixed headers, on-page links and overlapping content.
In Tailwind v4 you can use the variable straight from a class with pt-(--header-h), which keeps it in the same place as the rest of the layout's classes.
Make the header wrap less in the first place
Measuring fixes the overlap. It is also worth asking why the header grows at all. On Fotobank, from tablet width up, the header now stays on one row and the search box shrinks to make room instead of wrapping. Phones still get the two-row header, because a usable search box needs the width, and the variable handles that.
My rule of thumb: let the header wrap only at the width where you have decided it should, and give that state a default value in CSS. A header that wraps by accident at some in-between width, as this one did at 1024 px, is the version that surprises people.
A related layout bug: pages wider than the screen
The same pass turned up a second layout bug in the admin pages. They sat inside a column flexbox and were centred with mx-auto. In a column flex container, a child with auto horizontal margins shrinks to fit its content instead of stretching to the container's width. So a long slug or a wide table row made the page wider than the screen.
The fix was to drop the flex wrapper and put the pages in a plain block, where they take the full width and long content wraps or scrolls inside its own container. If a page is mysteriously wider than the viewport, check for mx-auto on a flex child before anything else.
Fotobank had a few other page-level fixes from around the same time, including share images missing from Next.js metadata. For layout changes like this, I like to check the result against the old pages screen by screen, in the spirit of verifying a refactor by diffing old and new output.
How do I stop a fixed header from covering content?
Give the content a top offset equal to the header's real height, set in one place. Measure the header with a ResizeObserver into a CSS variable such as --header-h, apply it as padding-top on the main element, and use scroll-padding-top with the same variable so anchor links land below the header.
The short version
Stop guessing the header's height in each page. Let the header measure itself into a CSS variable, give that variable a sensible default, and reserve the space once in the layout. Add scroll-padding-top for anchor links, and decide on purpose at which width the header may wrap. If your front end has a pile of layout fixes like these waiting, it is the kind of work I take on through my services.