CSS scroll-margin: Anchor Offsets
Anchor offsets without JavaScript scroll math
Smooth scrolling to an id still parked the heading under a sticky header, so people measured the header in JavaScript and called scrollTo. scroll-margin-block-start tells the browser to stop short of the target.
document.querySelectorAll('a[href^="#"]').forEach((link) => { link.addEventListener('click', (event) => { const id = link.getAttribute('href').slice(1); const target = document.getElementById(id); if (!target) return; event.preventDefault(); const top = target.getBoundingClientRect().top + window.scrollY - header.offsetHeight; window.scrollTo({ top, behavior: 'smooth' }); });});html { scroll-behavior: smooth;}@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; }}[id] { scroll-margin-block-start: 5rem;}/* no header measurement, no click handler */scroll margin Browser Support
This feature is well established and works across many devices and browser versions. It has been available across browsers since 2020.
Safe to use without fallbacks.
No offset math
The browser stops short of the target. No click listener, no getBoundingClientRect, no header.offsetHeight.
Same offset everywhere
Fragment links and scrollIntoView() both honor scroll-margin. One length covers every in-page jump.
Motion stays optional
scroll-behavior: smooth animates the jump. prefers-reduced-motion sets it back to auto, and the offset stays.
How it works
An in-page link scrolls the target's block-start edge to the start of the scrollport. A sticky or fixed header then covers the heading. The usual fix was a click listener: preventDefault, subtract header.offsetHeight from the target's position, and pass that number to window.scrollTo.
scroll-margin-block-start on the target adds space outside its block-start edge, and the browser includes that space when it brings the element into view. Set it to the header's block size, often with an [id] rule, and the heading lands just below the bar. The same outset applies to fragment navigation and scrollIntoView(). The value is a length. Percentages are not allowed, because it isn't defined what they would be relative to.
scroll-behavior: smooth on the scroller animates the jump. Inside @media (prefers-reduced-motion: reduce), set it back to auto so the offset remains and the animation does not. scroll-padding-block-start on the scroll container does the same job for every target in one declaration, which fits when one header height covers the page. Safari's older scroll-snap-margin name did not apply to fragment navigation or scrollIntoView(). That landed with scroll-margin in Safari 14.1 and iOS Safari 14.5. Firefox's full support is 90. If the sticky header itself has an id, an [id] rule gives that header the margin too. The headings you link to are the ones that need it.
scroll-behavior: smooth in @media (prefers-reduced-motion: reduce) and set it back to auto. People who opted out of motion still get the scroll-margin offset. They just don't get the animated jump. See prefers-reduced-motion.