Fixing Anchor Links That Land Behind a Sticky Header
Clicking an in-page "jump to section" link scrolls to the right place, but the heading itself ends up hidden under the sticky header, cutting off the first line of content. The scroll math isn't wrong — the browser just has no idea a fixed header is sitting on top of where it's about to land.
The Problem
A page has a fixed or sticky header — common for navigation that stays visible while scrolling — and also uses in-page anchor links: a table of contents, a "jump to section" nav, or a URL with a #section-id fragment. Clicking one of these links does scroll the page, and the browser's URL bar shows the correct fragment, but the destination heading lands partially or fully hidden behind the sticky header, cutting off the first line or two of the section it was supposed to reveal.
Why It Happens
Native anchor scrolling positions the target's top edge at the viewport's top edge — full stop
When a browser scrolls to an anchor (via a fragment URL or an explicit element.scrollIntoView() call), its calculation is simple: align the target element's top edge with the top of the visible viewport. That calculation has no concept of a sticky or fixed header, because a header positioned with position: fixed or position: sticky exists in its own stacking context, visually overlapping the page content rather than occupying document flow space that would push the target down.
The header's actual footprint isn't reflected in normal document layout
A header using position: static or position: relative pushes everything below it down in the document flow, so a target element's "top" already accounts for the header's height. A fixed or sticky header does the opposite by design — it stays out of that flow so the page can scroll underneath it — which is exactly what makes it overlap a freshly-scrolled-to target instead of sitting safely above it.
The Fix
1. Use scroll-margin-top on the anchor targets — the modern, CSS-only fix
.sticky-header {
position: sticky;
top: 0;
height: 72px;
}
/* apply to every heading/element that can be an anchor target */
h2[id], h3[id], .anchor-target {
scroll-margin-top: 88px; /* header height + a little breathing room */
}
scroll-margin-top is defined specifically for this case: it tells the browser's native scroll positioning to leave that much extra space above the target when scrolling to it, without changing the element's actual layout or requiring any JavaScript. It works for fragment-URL navigation, scrollIntoView(), and find-in-page — every native scrolling mechanism respects it uniformly.
2. Keep the offset value tied to the actual header height, not a guessed constant
:root {
--header-height: 72px;
}
.sticky-header {
height: var(--header-height);
}
h2[id], h3[id] {
scroll-margin-top: calc(var(--header-height) + 16px);
}
Defining the header's height as a CSS custom property and referencing it in the offset means a future header redesign that changes its height doesn't silently reintroduce the exact bug just fixed — the two values can't drift apart, since one is derived from the other.
3. For a header whose height changes dynamically (shrinks on scroll), update the property with JS
const header = document.querySelector(".sticky-header");
const root = document.documentElement;
function syncHeaderHeightVar() {
root.style.setProperty("--header-height", `${header.offsetHeight}px`);
}
syncHeaderHeightVar();
window.addEventListener("resize", syncHeaderHeightVar);
// also call this after any transition/animation that changes header height
If the header's height itself changes (a common pattern: a taller header that shrinks once the user scrolls past the top), a static offset can't stay correct in both states — syncing the custom property to the header's real, current height keeps scroll-margin-top accurate regardless of which header state is active when the scroll happens.
4. Reserve a JavaScript scrollIntoView with a manual offset only as a legacy fallback
function scrollToAnchor(id) {
const target = document.getElementById(id);
const headerHeight = document.querySelector(".sticky-header").offsetHeight;
const top = target.getBoundingClientRect().top + window.scrollY - headerHeight - 16;
window.scrollTo({ top, behavior: "smooth" });
}
scroll-margin-top has solid modern browser support, so a manual JS-calculated scroll is only worth maintaining as a fallback for very old browser targets — otherwise it's extra code duplicating something the browser now does natively and more consistently (including cases the CSS property handles automatically, like browser find-in-page).
Why This Works
scroll-margin-top resolves the actual mismatch directly: native scroll positioning was never wrong about where the target element's edge is, it simply had no way to know a fixed-position header would visually overlap that position afterward. Telling the browser explicitly how much extra clearance to leave closes that gap at the exact layer where the ambiguity exists, and tying the offset to the header's real height via a CSS custom property (updated by JS only if the header itself is dynamic) keeps the fix correct even as the header's design changes later.
Conclusion
Anchor links landing behind a sticky header happen because native scroll positioning has no built-in awareness of a fixed-position element overlapping the viewport — the fix is scroll-margin-top on every anchor target, set to (or dynamically synced with) the sticky header's actual height, which works uniformly across fragment-URL navigation, JavaScript scrolling, and browser find-in-page without requiring a hand-rolled scroll calculation.