749 words
4 minutes

scrollIntoView() Hidden by a Fixed Header: Use scroll-margin-top

2026-08-12
Frontend
CSS
/
JavaScript
/
Frontend
/
Troubleshooting

When scrollIntoView() moves a heading behind a fixed navigation bar, do not subtract a guessed number from window.scrollY first. Keep the scroll call simple and express the obstruction in CSS with scroll-margin-top on the target or scroll-padding-top on the scrolling container.

The smallest working pattern is:

:root {
--site-header-height: 4rem;
}
html {
scroll-padding-top: var(--site-header-height);
}
[data-scroll-target] {
scroll-margin-top: 1rem;
}

Then the same target can be reached through a hash link or JavaScript:

document.querySelector('#settings')?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});

Use one primary offset policy and measure a dynamic header when its height changes. A fixed 60px subtraction often breaks when the header wraps on a phone, a banner appears, or the user changes text size.

Choose the property at the right boundary#

scroll-padding-top describes the safe area inside a scroll container. It is a good default when the document viewport has one fixed header:

html {
scroll-padding-block-start: var(--site-header-height);
}

scroll-margin-top describes extra space around the element being scrolled into view. Use it when only selected headings need an offset, or when a component owns its own target rule:

h2,
h3,
[id] {
scroll-margin-block-start: calc(var(--site-header-height) + 1rem);
}

Do not blindly give the same large value to both properties. The browser can account for both the container’s scroll padding and the target’s scroll margin, so the result may leave too much empty space. Start with scroll-padding-block-start on html; add target margin only where a component needs extra breathing room.

The logical block-start forms also work when a layout changes writing mode. If the site is strictly horizontal, scroll-padding-top and scroll-margin-top are easier to search for while debugging.

Keep the JavaScript focused on behavior#

The DOM API determines which element to reveal and how it should move. CSS determines where that element may settle:

const link = document.querySelector('[data-open-settings]');
const target = document.querySelector('#settings');
link?.addEventListener('click', () => {
target?.scrollIntoView({
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches
? 'auto'
: 'smooth',
block: 'start',
inline: 'nearest',
});
});

This keeps the reduced-motion preference in the interaction code without baking the header’s current pixel height into every event handler. It also lets normal #settings links use the same offset.

If the header height is dynamic, publish the measured value as a CSS custom property:

const header = document.querySelector('[data-site-header]');
if (header) {
const updateHeaderOffset = () => {
const height = header.getBoundingClientRect().height;
document.documentElement.style.setProperty(
'--site-header-height',
height + 'px',
);
};
updateHeaderOffset();
new ResizeObserver(updateHeaderOffset).observe(header);
}

The observer is useful for a responsive header that changes after hydration. Keep it attached to the element that actually covers the viewport; measuring a hidden desktop navigation element produces the wrong offset.

Handle nested scrolling containers separately#

If the target is inside a panel with its own overflow: auto, setting padding on html may not affect that panel. Put the safe area on the container that scrolls:

.settings-panel {
overflow: auto;
scroll-padding-block-start: 1rem;
}
.settings-panel [data-scroll-target] {
scroll-margin-block-start: 1rem;
}

Then inspect which ancestor actually moves when scrollIntoView() runs. A page-level header may cover the panel even when the panel itself has no header; in that case, reserve space in the layout or use a target-specific margin that includes the visible obstruction.

Troubleshoot the failure in order#

Check these boundaries before changing the scroll math:

  1. Confirm the target exists and is an element, not a collection or a framework ref wrapper.
  2. Confirm the target has a stable id and the link points to the same value.
  3. Confirm the fixed or sticky header is actually covering the target after the scroll.
  4. Identify the scrolling container and apply padding there.
  5. Measure the header after fonts, banners, and responsive navigation have settled.
  6. Test keyboard focus, direct hash navigation, reduced motion, and a short viewport.

If the console says scrollIntoView is not a function, the issue is usually that the code selected a NodeList, a null value, or a framework ref rather than an Element. scrollIntoView Is Not a Function covers that type boundary; this article assumes the method runs but the target is visually covered.

FAQ#

Q: Should I use scroll-padding-top or scroll-margin-top for a fixed header?#

A: Start with scroll-padding-top on the page’s scrolling container when one header covers the viewport. Use scroll-margin-top on specific targets when a component needs its own offset or extra spacing.

Q: Why does a hard-coded 60px offset fail on mobile?#

A: Responsive headers can wrap, collapse, or gain a banner, so their real height changes. Measure the visible header and expose that value as a CSS custom property instead of duplicating a constant in JavaScript.

A: Yes. Scroll padding and scroll margin participate in the browser’s scroll-to-target behavior, so the same CSS can protect hash navigation and script-driven scrolling.

References:

MDN: Element.scrollIntoView()

MDN: scroll-margin-top

MDN: scroll-padding-top

Stack Overflow: Using scrollIntoView with a fixed position header

scrollIntoView() Hidden by a Fixed Header: Use scroll-margin-top
https://laplusda.com/en/posts/scrollintoview-fixed-header-offset/
Author
Zero
Published at
2026-08-12
License
CC BY-NC-SA 4.0
Was this article useful?

Report a typo or broken link, or suggest a related topic.