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.

Fragment navigation with and without scroll-padding Left, the target heading's top aligns with the viewport top and is hidden behind the sticky header. Right, scroll-padding-top equal to the header height moves the snapport's top edge down, so the heading lands below the header. Where #section-3 lands after clicking its link sticky header heading hidden above this line paragraph after it no scroll-padding sticky header #section-3 heading paragraph after it scroll-padding-top: header

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.

Live demoFragment links under a sticky header, with and without scroll-padding
Click the links in each panel: on the left the heading lands under the header; on the right scroll-padding-top keeps it visible.

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.

Two offsets that add together A scroll container whose snapport starts below a scroll-padding strip. A target whose alignment box starts above it by its scroll-margin. The alignment box's top meets the snapport's top, so the element sits padding plus margin below the scroller's edge. Final gap = scroll-padding + scroll-margin scroll-padding-top (on the scroller) scroll-margin-top (on the target) target element snapport top Padding: same for every target. Margin: per target, for exceptions.

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() without preventScroll, 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-type is 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 articles

More pages in the same section.