Read this scroll as text
Full text of this interactive scroll.
- Live Demo
- Context
- The Properties
- Which One?
- Gotchas
Your nav bar is eating your headings.
Two CSS properties fix it. They're not the same. Here's the demo first, then the explanation.
scroll-padding-top scroll-margin-top 01 Live DemoSee it land
A scroll container with its own sticky header. Pick a mode, jump to any section, watch where the heading lands. The teal line marks the offset boundary when a fix is active. Switch modes and re-click the same button to compare directly.
Mode: No fix scroll-padding-top scroll-margin-topScroll container
Sticky Nav Content above first section… Section OneDoes the heading clear the bar, or slide beneath it? That's the whole question.
Section TwoTaller content to give enough scroll range to show the offset clearly.
More content — more content — keep going.
Section ThreeSwitch modes, re-click the same link. Compare the exact landing position.
Section FourSame result, different architecture. That's the whole lesson.
Jump to section
→ Section One → Section Two → Section Three → Section Four Mode: No fix Applied CSS: none 02 ContextSame fix. Different owner.
You have a sticky nav. You have anchor links. The browser scrolls the target to the top edge of the scrollport — directly behind your bar. The old workaround was a ghost element: margin-top: -80px; padding-top: 80px on every target. Works until nav height changes. Works until someone deletes it.
"The browser isn't wrong. It put the element at the top. You just forgot something already lives there."Both properties offset where "arrived" means. The only distinction is which element each goes on.
Where each property lives in the DOMscroll-padding-top — on the container
┌ <html> scroll-padding-top: 64px ├ <nav> sticky, 64px ├ <section id="a"> └ <section id="b">One declaration. Every anchor jump in the container respects it.
scroll-margin-top — on the element
┌ <html> ├ <nav> sticky, 64px ├ <section id="a"> scroll-margin-top: 64px └ <section id="b"> scroll-margin-top: 80pxPer-element. Each target declares its own offset independently.
The short version: same offset everywhere? Put scroll-padding-top on html once. Different offsets per element, or nested scroll containers? Use scroll-margin-top on the targets.
03 Deep DiveThe properties
scroll-padding-top scroll-margin-top On the containerInsets the scroll container's optimal viewing region from the top. When the browser scrolls to any anchor inside the container, it lands the element within this inset area — not flush with the raw top edge.
Applies to: Scroll containers ✓ Percentages accepted ✕ No negative values Also affects: scroll-snap alignment Shorthand: scroll-padding /* One line. Fixes every anchor link on the page. */ html { scroll-padding-top : 64px ; } /* Tie it to a custom property for easy maintenance */ :root { --nav-h : 64px ; } html { scroll-padding-top : var(--nav-h) ; } /* Works on any scroll container, not just html */ .sidebar { overflow-y : scroll ; scroll-padding-top : 48px ; } CopyAlso moves snap points
scroll-padding-top shifts where scroll-snap-align: start children snap, not just where anchor links land. One property on the container. Zero per-item work.
Don't use :root. They select the same element, but the property targets the scroll container. Use the html type selector. :root silently fails.
On the elementExpands the target element's scroll snap area outward. The browser acts as if the element starts N pixels higher. Zero layout impact — box model, padding, margin are untouched. Only the scroll algorithm sees this.
Applies to: All elements ✓ Negative values valid ✕ No percentages (silent fail) Layout impact: none Shorthand: scroll-margin /* Per-element, per-target */ #about { scroll-margin-top : 80px ; } #features { scroll-margin-top : 64px ; } /* scrollIntoView() respects it too — no JS offset math */ el.scrollIntoView({ behavior : 'smooth' , block : 'start' }); /* Negative values: scroll-padding-top can't do this */ .overlap { scroll-margin-top : -8px ; } CopyNo margin collapsing
Adjacent CSS margins collapse. scroll-margin values do not. Two adjacent sections each with scroll-margin-top: 40px each get the full 40px. Despite the name, these are scroll hints — not box-model margins.
Percentages fail silently. scroll-margin-top: 10% is a parse error. The browser ignores it. Length values only: px , rem , em .
04 Decision GuideWhich one?
Three questions. First match wins.
Do all your anchor targets need the same offset?
Yes — same everywhere No — different per target→ scroll-padding-top on html
One declaration. Done. Tie it to a CSS custom property if nav height might change.
html { scroll-padding-top : var(--nav-height) ; } Copy→ scroll-margin-top per element
Set it on each target independently. The container stays untouched.
.sec-a { scroll-margin-top : 80px ; } .sec-b { scroll-margin-top : 40px ; } CopyDo you need a negative offset?
Yes — pull past the edge No — positive only→ Must use scroll-margin-top
scroll-padding-top treats negatives as 0 . scroll-margin-top accepts them.
→ Either works
Both properties behave correctly with positive values. Default to whichever fits question one.
Is the target inside a nested scroll container?
Yes — inside overflow: scroll No — regular page scroll→ Apply to the inner container, or scroll-margin-top on the target
The browser scrolls the nearest scrollable ancestor. scroll-padding-top on html won't reach it.
→ scroll-padding-top on html
Standard case. One property, done.
05 Edge Cases & GotchasThings the docs gloss over
Click any row to expand.
Naming The names are actively misleading ▾scroll-margin-top adds no visible margin. scroll-padding-top adds no padding. Both are purely scroll-behavior directives. Neither shows up in the box model or DevTools layout panels.
:root vs html scroll-padding-top on :root does nothing ▾:root and html select the same element, but the property must target the scroll container context. Use the html type selector. :root silently fails.
Percentages scroll-margin-top rejects percentages without warning ▾scroll-padding-top: 10% is valid. scroll-margin-top: 10% is a parse error the browser ignores silently. Length values only on scroll-margin: px , rem , em .
Dynamic navs Update when nav height changes ▾If your sticky nav collapses on scroll, a hardcoded offset is wrong half the time. Store nav height in a CSS custom property, update it via JS, and reference the variable in your scroll offset property.
Nested containers Scroll context is everything ▾If the target is inside a nested overflow: scroll container, the browser scrolls that container, not the page. scroll-padding-top on html has no effect. Apply it to the inner container, or put scroll-margin-top on the target.
JavaScript scrollIntoView() respects both ▾element.scrollIntoView({ block: 'start' }) respects scroll-margin-top on the target. Modern browsers also respect scroll-padding-top on the container. No JS offset math needed.
No collapse scroll-margin values don't collapse ▾Adjacent CSS margins collapse. Adjacent scroll-margin-top values do not. Each element gets its full declared offset when scrolled to.
Reduced motion These are destination properties, not animation ▾prefers-reduced-motion has no effect on either property. Do still set scroll-behavior: auto inside a reduced-motion query to disable the smooth animation separately.
Browser Support| Browser | scroll-padding-top | scroll-margin-top |
|---|---|---|
| Chrome / Edge | ✓ since 69 / 79 | ✓ since 69 / 79 |
| Firefox | ✓ since 68 | ✓ since 68 |
| Safari | ✓ since 14.1 | ✓ since 14.1 |
| IE 11 | ✕ | ✕ |
Baseline since 2020. Ship it.
En DashBuilding something that needs to scroll right?
En Dash works with product teams on interactive experiences, design systems, and the front-end problems that live between "it works" and "it's actually good."
Talk to En Dash Read: Modal vs. Modeless →