scrollIntoView() Hidden by a Fixed Header: Use scroll-margin-top
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:
- Confirm the target exists and is an element, not a collection or a framework ref wrapper.
- Confirm the target has a stable
idand the link points to the same value. - Confirm the fixed or sticky header is actually covering the target after the scroll.
- Identify the scrolling container and apply padding there.
- Measure the header after fonts, banners, and responsive navigation have settled.
- 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.
Q: Does this fix normal anchor links as well as scrollIntoView()?
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:
Stack Overflow: Using scrollIntoView with a fixed position header
Report a typo or broken link, or suggest a related topic.