scroll-margin and scroll-padding: Keeping Targets Clear of Sticky Headers
A page with a sticky header has a quiet usability bug. Click a table-of-contents link and the target heading scrolls to the very top of the viewport — directly under the header, so the reader sees the paragraph below it and wonders whether the link worked. Tab through a long form and each focused field scrolls to the top edge, hidden behind the same header, so keyboard users cannot see what they are typing into. Both problems have the same one-line fix, and it is one of the least-known properties in CSS. This page explains scroll-padding and scroll-margin, which one to use when, and how they interact with every way a page scrolls to an element. It belongs to Scroll Snap & Overflow Layouts in the Mastering Container Queries & Responsive Layouts guide.
Why the browser puts targets under the header
When the browser scrolls an element into view — for a fragment link, a focus change, element.scrollIntoView(), or find-in-page — it computes a target position that aligns the element with the edge of the scroll container's snapport. By default the snapport is the whole visible area of the scroller, so "align to the top" means the element's top edge meets the viewport's top edge.
A sticky or fixed header occupies the top of that visible area, but the browser has no idea it is there: positioning is a visual matter, and scrolling logic does not consult it. The two properties on this page give the scrolling logic the information it is missing. scroll-padding shrinks the snapport from the scroll container's side. scroll-margin enlarges each target's alignment box from the element's side.
The keyboard case is the one that makes this an accessibility requirement rather than polish. WCAG 2.4.11 Focus Not Obscured (Minimum), added in WCAG 2.2, requires that a focused component is not entirely hidden by author-created content such as a sticky header. scroll-padding is the most direct way to meet it for scrolling caused by focus changes.
The complete implementation
The demo reproduces the bug and the fix side by side. Each panel is a small scrolling page with a sticky header; only the right-hand one sets scroll-padding-top. Click the same link in both.
The full page below keeps the header height in one custom property and uses it in three places: the header's own height, the root scroller's scroll-padding-top, and a slightly larger scroll-margin-top on headings so they breathe below the header.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sticky header offsets</title>
<style>
:root {
--header-h: 3.5rem;
}
/* The ROOT element is the document scroller, so scroll-padding goes
here, not on body. Every scroll-into-view now treats the top
3.5rem as off limits: fragment links, focus, scrollIntoView(). */
html {
scroll-padding-top: var(--header-h);
}
@media (prefers-reduced-motion: no-preference) {
html { scroll-behavior: smooth; }
}
body { margin: 0; font: 16px/1.6 system-ui, sans-serif; }
.site-header {
position: sticky;
top: 0;
z-index: 10;
height: var(--header-h);
display: flex;
align-items: center;
padding-inline: 1rem;
background: #0f172a;
color: #f8fafc;
}
main { max-width: 42rem; margin: 0 auto; padding: 1rem; }
/* Headings get a little extra room on top of the padding, so the
heading does not sit flush against the header's bottom edge.
scroll-margin ADDS to scroll-padding for this target. */
main h2 {
scroll-margin-top: 0.75rem;
}
/* Highlight the fragment target briefly so readers can see where
they landed; the highlight is static under reduced motion. */
main h2:target {
background: #fef3c7;
}
</style>
</head>
<body>
<header class="site-header">Handbook</header>
<main>
<nav aria-label="On this page">
<a href="#setup">Setup</a> · <a href="#usage">Usage</a> · <a href="#faq">FAQ</a>
</nav>
<h2 id="setup">Setup</h2>
<p>Long content…</p>
<h2 id="usage">Usage</h2>
<p>Long content…</p>
<h2 id="faq">FAQ</h2>
<p>Long content…</p>
</main>
</body>
</html>
The division of labour is the lesson. The header covers the same strip of the viewport for every target, so its height belongs on the scroller as scroll-padding, set once. The extra breathing room is a property of headings specifically — form fields do not need it — so it belongs on the targets as scroll-margin.
The key technique: padding on the scroller, margin on the target
The two properties sound symmetrical and are easy to confuse. The diagram shows where each one acts.
Both properties come in physical and logical longhands — scroll-padding-top, scroll-padding-block-start, scroll-margin-inline and so on — and both accept lengths and percentages. scroll-padding also accepts auto, which lets the browser pick a value; in practice explicit values are more predictable. Neither property has any effect on layout: they change only where scrolling operations stop, never the size or position of boxes.
The same properties serve scroll snap. Snap alignment is computed against the snapport too, so a carousel's scroll-padding-inline decides where slides come to rest, and a slide's scroll-margin-inline can offset an individual slide. One concept — the snapport — covers snapping, fragment navigation and focus scrolling alike.
Every way a page scrolls to an element
It is worth listing the triggers explicitly, because teams often test the fragment-link case, see it fixed, and assume the job is done. The snapport rules apply to all of these:
- Fragment navigation. Clicking
<a href="#usage">, loading a URL with a hash, or using the browser's back button to return to a fragment. - Sequential focus navigation. Tabbing to a link, button or field that is outside the visible area, and the browser scrolling it into view.
- Programmatic scrolling.
element.scrollIntoView(),element.focus()withoutpreventScroll, and framework router helpers that call them. - Find in page. The browser scrolls each match into view when the user searches with Ctrl+F or Cmd+F.
- Text fragments. Links of the form
#:~:text=phrase, which search engines and share sheets generate, scroll the highlighted text into view. - Snap settling. When
scroll-snap-typeis set on the scroller, resting positions after a gesture use the same snapport.
Because a single declaration on the scroller covers all six, scroll-padding is one of the highest-value, lowest-risk properties you can add to a site with a sticky header. The only case it cannot help is scrolling that the page performs with explicit coordinates — window.scrollTo(0, y) — because there is no target element to align. Code that computes scroll positions manually should subtract the header height itself, or better, be rewritten to use scrollIntoView() so it inherits the CSS offset.
Test the focus case deliberately: open a long form, click into the last field, then press Shift+Tab repeatedly while watching each field scroll into view. Every focused field should appear fully below the header. If one does not, check whether an ancestor between it and the root is itself a scroll container, which would need its own scroll-padding.
Variation: headers that change height
Real headers are rarely a single fixed height. They grow a second row on narrow screens, shrink on scroll, or gain an announcement banner. Tie every dependant to the same custom property and update the property wherever the height changes.
:root { --header-h: 3.5rem; }
/* Two-row header on narrow screens: one variable change updates the
header, the scroll offset and anything else that depends on it. */
@media (width < 40rem) {
:root { --header-h: 6rem; }
}
/* An announcement banner above the header, only while it exists. */
:root:has(.announcement:not([hidden])) {
--header-h: calc(3.5rem + 2.5rem);
}
html { scroll-padding-top: calc(var(--header-h) + 0.5rem); }
.site-header { min-height: var(--header-h); }
A header that shrinks on scroll with a scroll-driven animation is the hardest case, because its height is continuous rather than stepped. Set scroll-padding-top to the expanded height: when a fragment link scrolls far enough for the header to collapse, the target simply has a little more room than necessary, which is harmless, whereas padding for the collapsed height would hide targets under the expanded header near the top of the page.
For headers inside an app shell where an inner <main> scrolls instead of the document, move scroll-padding onto that inner scroller. The property must be on the element that actually scrolls, which is also why setting it on body usually does nothing.
Browser support
scroll-padding and scroll-margin, including all their longhands, are supported in all current versions of Chrome, Edge, Firefox and Safari, and they apply to fragment navigation, focus scrolling and scrollIntoView() in each. scroll-behavior: smooth is supported in Chrome 61+, Edge 79+, Firefox 36+ and Safari 15.4+. :has(), used for the banner variation, is supported in Chrome and Edge 105+, Firefox 121+ and Safari 15.4+.
FAQ
Should I use scroll-padding or scroll-margin for a sticky header?
Use scroll-padding-top on the scrolling element, usually html, because the header covers the same strip for every target. Use scroll-margin-top on individual targets only when some of them need extra or different space, such as headings that should sit a little lower.
Does scroll-padding affect focus scrolling from the Tab key?
Yes. When focus moves to an element that is out of view, browsers scroll it into view using the same snapport rules, so scroll-padding keeps focused inputs and links from landing under a sticky header. That makes it an accessibility fix, not just a cosmetic one.
Why does scroll-padding on body do nothing?
Because the document's scroller is normally the root element, html, not body. scroll-padding must be set on the element that actually scrolls. Set it on html, or on whichever inner element has overflow: auto if the page uses an app-shell layout.
How do I keep scroll-padding in sync with a header whose height changes?
Store the header height in a custom property and use it for both the header and scroll-padding-top. If the height is only known at runtime, set the property from script when it changes; CSS will update the scroll offset automatically.
Related
- Scroll Snap & Overflow Layouts — the parent guide to scrolling behaviour in layouts.
- overscroll-behavior for Nested Scrolling — the other scroller property most pages need.
- Creating Accessible Focus Indicators — making the element that scrolled into view visibly focused.
- Scroll Progress Bar Without JavaScript — scroll-linked header effects that share the same header.
Related articles
More pages in the same section.