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.

Old way9 lines
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' });  });});
11 lines
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 */
Widely availableSince 202094% global usage

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.

69+
68+
11+
79+
Interop 2026 focus area?Learn more →
click a section — the right heading clears the bar
no offset
Header

One

The heading slides under the bar.

Two

Same jump, still covered.

Three

Nothing holds it below the header.

scroll-margin-block-start
Header

One

The heading clears the bar.

Two

The outset matches the header.

Three

No JavaScript measured that height.

Left heading slides under the bar. Right heading stops below it.

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.

Lines Saved
No offset math
Header height stays in CSS
Old Approach
scrollTo + header height
Click listener on every anchor
Modern Approach
scroll-margin-block-start
Browser includes the outset

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.

♿
Accessibility note: Wrap 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.
ESC