Keep Anchor Links Below Sticky Headers With scroll-margin
An anchor link can land on the right heading and still leave it hidden beneath a sticky header. Add scroll-margin-top to the target to give the browser extra space when positioning it.
The heading keeps its ordinary place in the layout. The offset belongs to the scrolling behaviour, so there is no need to insert an invisible spacer above every section.
View code
<label><input id="fix" type="checkbox"> Apply scroll-margin-top: 3.5rem</label>
<div class="scroller" tabindex="0" role="region" aria-label="Article with sticky toolbar">
<nav><button type="button" id="jump">Jump to pricing</button></nav>
<p class="intro">Scroll or use the button to reach the pricing section.</p>
<h2 id="pricing">Pricing</h2><p class="after">The heading should remain visible below the toolbar.</p>
</div>button, select { padding: .35rem .6rem; border: 1px solid var(--clg); background: var(--cw); color: var(--cb); }
input[type="range"] { width: min(100%, 18rem); vertical-align: middle; }
h2, h3 { font-size: 1rem; }
.note { color: var(--clg); }
.scroller { height: 14rem; overflow: auto; border: 1px solid var(--clg); margin-top: 1rem; }
nav { position: sticky; top: 0; padding: .5rem; background: var(--cg); z-index: 1; }
.intro { min-height: 12rem; padding: 1rem; }
h2 { margin: 0; padding-inline: 1rem; }
.after { min-height: 16rem; padding: 1rem; }
.scroller.fixed h2 { scroll-margin-top: 3.5rem; }const scroller = document.querySelector('.scroller');
document.querySelector('#fix').addEventListener('change', event => {
scroller.classList.toggle('fixed', event.target.checked);
scroller.scrollTop = 0;
});
// Keep navigation within the example instead of scrolling the parent article.
document.querySelector('#jump').addEventListener('click', () => {
document.querySelector('#pricing').scrollIntoView({block: 'start', container: 'nearest'});
});Put scroll-margin on the heading being linked to
A link and its target can stay ordinary HTML:
<a href="#pricing">Pricing</a>
<h2 id="pricing">Pricing</h2>
For a header around 5rem tall, a 6rem offset leaves room for the header and a small gap:
h2[id],
h3[id] {
scroll-margin-top: 6rem;
}
The scroll-margin-top property adds an outset to the target’s scroll area. The scroll positioning specification also applies that area when navigating to a fragment target, even without scroll snapping.
The 6rem value is an example, not a header size the browser discovers automatically. Choose it from the actual navigation height and the amount of space the heading needs below it.
Keep the change separate from normal spacing
Padding above a heading would create visible space in its ordinary layout. A scroll margin changes the area considered for scroll positioning without inserting that extra gap into the article.
That distinction matters when headings already have suitable margins. Fixing where a link lands should not require opening up every section on the page.
The heading selectors also keep the rule scoped. A broad [id] selector would affect other targets too, which may be appropriate for a whole site but can include elements that need different offsets.
Copied heading links need the same offset
A table of contents is only one route to a section. Someone can open a URL containing a fragment directly, or copy a heading link from the article.
A Hugo heading render hook might produce markup like this:
<h2 id="browser-support">
Browser support
<a href="#browser-support" aria-label="Link to Browser support">#</a>
</h2>
The earlier CSS matches this heading too. There is no separate offset to add to each generated link.
Check both clicking the link and opening its full URL in a new tab. Keyboard activation is another route through the same anchor navigation; the CSS does not replace focus handling or guarantee that focus moves to the heading.
Keep the offset connected to the header design
A custom property can keep the header height and target offset together:
:root {
--header-height: 5rem;
--anchor-gap: 1rem;
}
.site-header {
position: sticky;
top: 0;
box-sizing: border-box;
height: var(--header-height);
}
h2[id],
h3[id] {
scroll-margin-top: calc(var(--header-height) + var(--anchor-gap));
}
@media (min-width: 60rem) {
:root {
--header-height: 6rem;
}
}
This is an alternative to the first offset rule. It suits a design with an explicitly sized header; the values still need to agree with the actual component.
A header that grows when navigation wraps needs a different sizing decision. A breakpoint variable cannot measure arbitrary content growth, and forcing a fixed height around overflowing text only moves the problem elsewhere.
A little space below the header usually reads better than landing directly against its edge. Keeping that gap as a separate value makes it easier to adjust without misrepresenting the header’s height.
Choose the target or the scroll container
scroll-padding-top reserves an inset in the scroll container’s optimal viewing region. It can serve the same sticky-header requirement from the container side.
For an article’s heading links, I prefer scroll margins because the selector makes their scope clear. A permanent toolbar covering an entire scrollable panel may be better described by padding on that panel’s scrollport.
Do not apply a full header-sized offset on both sides without checking the result. Target margins and container padding participate in the same alignment, so the resulting gap can be larger than intended.
Smaller scroll containers use the same model
The scroll-margin definitions apply to targets in scroll containers, including scroll-snap layouts. A card in a horizontal gallery can have a margin in the scrolling direction as well.
For a page with a header physically at the top, scroll-margin-top states that relationship directly. The logical block-start version is useful when the offset should follow writing mode rather than a physical edge.
Keep the offset attached to the obstruction it is meant to account for. A top-of-page navigation bar and an internal panel’s toolbar need not use the same value.
Make smooth scrolling a separate choice
scroll-behavior controls whether relevant scrolling happens instantly or smoothly. It does not supply the space needed below a header.
If animation suits the page, it can be enabled only when the reader has not requested reduced motion:
@media (prefers-reduced-motion: no-preference) {
html {
scroll-behavior: smooth;
}
}
The reduced-motion preference then leaves the base behaviour alone. The heading’s scroll margin works independently of this choice.
Check targets close to the page boundaries
The browser cannot always create the requested clearance if the document has no remaining scroll range. A heading near the bottom can stop short of the ideal position because the page cannot scroll farther.
Check the last linked section as well as one in the middle. If the final section needs more room, consider the page’s actual bottom spacing rather than continually increasing the heading’s offset.
Repeat the check after increasing text size or expanding navigation. The useful offset is the one that keeps the target readable in the layout the reader actually has.
Filed under CSS and Accessibility on