Type something to search...
scroll-behavior and scroll-margin: Better In-Page Navigation

scroll-behavior and scroll-margin: Better In-Page Navigation

In-page links are everywhere: tables of contents on long articles, "back to top" buttons, skip links, FAQ jump lists, and one-page marketing sites where the whole navigation is a set of #section anchors. They're also one of the most common sources of small, nagging UX bugs. Click a link and the page teleports instantly, leaving the reader unsure where they landed. Or it scrolls to the right spot, but the heading you wanted is hidden under your sticky header.

Two CSS properties solve these problems cleanly. scroll-behavior controls how the browser scrolls to a target, and scroll-margin (along with its sibling scroll-padding) controls where the target ends up. This guide covers both, shows how they combine with sticky headers and reduced-motion preferences, and walks through a complete table-of-contents example.

The Problem with Default Anchor Jumps

By default, when someone clicks a link like this:

<a href="#installation">Installation</a>

<h2 id="installation">Installation</h2>

the browser scrolls the page so that the top edge of the #installation element touches the top of the viewport, and it does so instantly. That creates two issues:

  1. Disorientation : An instant jump gives no sense of direction. Did the page move up or down? How far? On a long page, users can lose their place.
  2. Hidden targets : If you have a sticky or fixed header, the heading lands directly underneath it. The user sees the paragraph after the heading, but not the heading itself.

Smooth Scrolling with scroll-behavior

The scroll-behavior property accepts two values:

  • auto : the default, instant jump.
  • smooth : an animated scroll to the target.

To make all anchor navigation on a page smooth, set it on the root element:

html {
  scroll-behavior: smooth;
}

This applies to navigation triggered by fragment links, by changing location.hash, and by JavaScript scrolling methods like scrollIntoView() or scrollTo() that don't specify their own behavior. It does not affect the user's own scrolling with a mouse wheel, trackpad, or keyboard, which always follows the platform's native behavior.

scroll-behavior is supported in all current major browsers. The browser decides the exact duration and easing, and you can't customize them in CSS. That's intentional: it keeps scrolling consistent with the platform.

Why set it on html and not body?

The scrolling element for the main document is the root html element in standards mode. Setting scroll-behavior on body generally has no effect on page-level anchor jumps. If you're styling a scrollable panel instead, put the property on that panel:

.docs-sidebar {
  overflow-y: auto;
  scroll-behavior: smooth;
}

Always respect reduced motion

Smooth scrolling across a long page is exactly the kind of large, unexpected motion that can trigger discomfort for people with vestibular disorders. Operating systems let users request reduced motion, and CSS exposes that preference through a media query. The safest pattern is to enable smooth scrolling only when the user hasn't asked for less motion:

@media (prefers-reduced-motion: no-preference) {
  html {
    scroll-behavior: smooth;
  }
}

This is better than setting smooth globally and turning it off for reduce, because it defaults to the instant jump in any browser that doesn't support the media query. It's a small detail, but it's the kind that separates a considerate site from a careless one.

Smooth scrolling from JavaScript

If you scroll with JavaScript, you can request smoothness per call:

document
  .querySelector("#pricing")
  .scrollIntoView({ behavior: "smooth", block: "start" });

Passing behavior explicitly overrides the CSS setting. That means your script also needs to check the motion preference:

const prefersReduced = window.matchMedia(
  "(prefers-reduced-motion: reduce)",
).matches;

document.querySelector("#pricing").scrollIntoView({
  behavior: prefersReduced ? "auto" : "smooth",
  block: "start",
});

Alternatively, omit behavior entirely (or pass "auto", which means "use the CSS value") and let the stylesheet decide.

Offsetting Targets with scroll-margin

Now for the sticky header problem. scroll-margin adds space around an element that the browser takes into account whenever it scrolls that element into view. It doesn't change the layout at all; it only changes where the element comes to rest.

.site-header {
  position: sticky;
  top: 0;
  height: 4rem;
  z-index: 10;
}

h2[id],
h3[id] {
  scroll-margin-top: 5rem;
}

With a 4rem header, a scroll-margin-top of 5rem parks each heading one rem below the header. The heading is fully visible, and there's a little breathing room.

scroll-margin is a shorthand, like margin, with longhands for each side and logical versions:

.target {
  scroll-margin: 1rem;
  scroll-margin-top: 5rem;
  scroll-margin-block-start: 5rem;
  scroll-margin-inline: 2rem;
}

Using scroll-margin-block-start instead of scroll-margin-top keeps things correct in vertical writing modes, though for most horizontal-text sites the two are equivalent.

Keeping the offset in sync with the header

Hardcoding 5rem works until someone changes the header height. Store the height in a custom property and derive the offset from it:

:root {
  --header-height: 4rem;
}

.site-header {
  position: sticky;
  top: 0;
  height: var(--header-height);
}

:target,
h2[id],
h3[id] {
  scroll-margin-top: calc(var(--header-height) + 1rem);
}

@media (width < 48rem) {
  :root {
    --header-height: 3.25rem;
  }
}

Now the header and every scroll offset update together at each breakpoint.

The Alternative: scroll-padding on the Container

scroll-margin sits on the target. Its counterpart, scroll-padding, sits on the scroll container and insets the area that counts as "in view" for every target inside it.

html {
  scroll-padding-top: calc(var(--header-height) + 1rem);
}

This single rule has the same effect as putting scroll-margin-top on every possible target. So which should you use?

  • Use scroll-padding on html when the offset is caused by something global, like a sticky site header. One rule covers every anchor, including ones you didn't anticipate.
  • Use scroll-margin on targets when certain elements need extra or different spacing, such as a heading that should sit lower than a figure.

The two add together, so you can combine them: a global scroll-padding-top for the header, plus a small scroll-margin-top on headings for extra room.

There's also a useful side effect of scroll-padding. When users press Page Down or the space bar, browsers use the scroll container's padding to decide how far to move. With scroll-padding-top set, content that scrolls up doesn't get lost behind the sticky header between page jumps. That doesn't work with scroll-margin, which is another point in favor of scroll-padding for header offsets.

These Properties Affect More Than Anchor Links

Both properties apply whenever the browser scrolls an element into view, not just for fragment links. That includes:

  • Keyboard focus : When a user tabs to a link or form field that's off screen, the browser scrolls it into view, respecting scroll-padding on the container. This keeps focused inputs from hiding under the sticky header, which is an accessibility improvement in itself.
  • scrollIntoView() calls : The offset is applied automatically, so you don't need to calculate header.offsetHeight in JavaScript anymore.
  • Scroll snapping : scroll-margin and scroll-padding also define snap positions in scroll-snap containers.
  • Find in page : Browsers vary here, but many respect scroll padding when scrolling to a search match.

A Complete Example: Article with a Table of Contents

Let's put it all together on a long article with a sticky header and a table of contents.

<header class="site-header">
  <a href="/" class="logo">TideWave</a>
</header>

<main class="article">
  <nav class="toc" aria-label="Table of contents">
    <h2>On this page</h2>
    <ol>
      <li><a href="#intro">Introduction</a></li>
      <li><a href="#setup">Setup</a></li>
      <li><a href="#usage">Usage</a></li>
      <li><a href="#faq">FAQ</a></li>
    </ol>
  </nav>

  <article class="content">
    <h2 id="intro" tabindex="-1">Introduction</h2>
    <p>...</p>
    <h2 id="setup" tabindex="-1">Setup</h2>
    <p>...</p>
    <h2 id="usage" tabindex="-1">Usage</h2>
    <p>...</p>
    <h2 id="faq" tabindex="-1">FAQ</h2>
    <p>...</p>
  </article>
</main>

<a href="#top" class="back-to-top">Back to top</a>
:root {
  --header-height: 4rem;
}

html {
  scroll-padding-top: calc(var(--header-height) + 1.5rem);
}

@media (prefers-reduced-motion: no-preference) {
  html {
    scroll-behavior: smooth;
  }
}

.site-header {
  position: sticky;
  top: 0;
  z-index: 10;
  display: flex;
  align-items: center;
  height: var(--header-height);
  padding-inline: 1.5rem;
  background: rgb(255 255 255 / 0.9);
  backdrop-filter: blur(8px);
  border-bottom: 1px solid #e2e8f0;
}

.article {
  display: grid;
  grid-template-columns: 14rem 1fr;
  gap: 3rem;
  max-width: 68rem;
  margin-inline: auto;
  padding: 2rem 1.5rem;
}

.toc {
  position: sticky;
  top: calc(var(--header-height) + 1.5rem);
  align-self: start;
}

.toc a {
  color: #475569;
  text-decoration: none;
}

.toc a:hover {
  color: #1d4ed8;
}

.content h2:target {
  animation: highlight 1.5s ease-out;
}

@keyframes highlight {
  from {
    background: #fef3c7;
  }
}

.content h2:focus {
  outline: none;
}

@media (width < 56rem) {
  .article {
    grid-template-columns: 1fr;
  }

  .toc {
    position: static;
  }
}

A few details worth calling out:

  • The TOC is sticky too, and its top uses the same header variable so it sits just below the header.
  • The :target highlight briefly flashes the destination heading with a soft yellow background. Combined with smooth scrolling, it gives the user an unmistakable "you are here" signal. Only background is animated, and it's brief, but you can wrap it in the reduced-motion query too if you prefer.
  • tabindex="-1" on headings makes them programmatically focusable. Some browsers move keyboard focus to the target when following a fragment link only if it's focusable; this ensures that pressing Tab after following a link continues from the target, not from the top of the page. Because the headings aren't interactive, the focus outline is removed from them, while links keep theirs.

The "Back to top" link

The link above points to #top. The HTML specification treats #top specially: if there's no element with id="top", the browser scrolls to the top of the document. That means you don't even need a target element, and with scroll-behavior: smooth, the return trip is animated.

.back-to-top {
  position: fixed;
  right: 1.5rem;
  bottom: 1.5rem;
  padding: 0.6rem 1rem;
  border-radius: 999px;
  background: #1d4ed8;
  color: #fff;
  text-decoration: none;
}

Highlighting the Current Section in the TOC

A frequent follow-up request is to highlight the TOC link for the section currently on screen. CSS alone can't fully do that across browsers yet, because it requires knowing which section is visible. A small IntersectionObserver handles it:

const links = new Map(
  [...document.querySelectorAll(".toc a")].map((a) => [a.hash.slice(1), a]),
);

const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        links.forEach((a) => a.removeAttribute("aria-current"));
        links.get(entry.target.id)?.setAttribute("aria-current", "true");
      }
    });
  },
  { rootMargin: "-20% 0px -70% 0px" },
);

document
  .querySelectorAll(".content h2[id]")
  .forEach((h) => observer.observe(h));
.toc a[aria-current="true"] {
  color: #1d4ed8;
  font-weight: 600;
}

Using aria-current rather than a class means screen reader users also learn which section is current. Newer CSS features such as scroll-driven animations and scroll-state container queries may eventually make this possible without script, but support is still limited, so the observer remains the dependable option.

Common Pitfalls

  1. Smooth scrolling on body : It won't affect page-level anchor jumps. Use html.
  2. scroll-margin on the wrong element : If your id is on a section but the visible heading is inside it with a large top margin, the offset may look wrong. Put the id on the element you want to land on, or account for the extra spacing.
  3. Forgetting mobile headers : A header that changes height at a breakpoint needs its offset updated too. The custom property approach prevents this.
  4. Smooth scrolling that ignores preferences : Setting scroll-behavior: smooth without a reduced-motion check is the most common mistake here. It takes three extra lines to fix.
  5. Overflow on html or body : Setting overflow: hidden or overflow-x: hidden on body can change which element scrolls, and your scroll-padding on html may stop working. If anchor offsets suddenly break, check for stray overflow rules.
  6. Expecting control over duration : CSS can't set the speed or easing of smooth scrolling. If a design requires a specific curve, that's a JavaScript job, but consider whether it's worth overriding the platform.

Browser Support

scroll-behavior, scroll-margin, and scroll-padding are supported in all current major browsers. Older browsers that don't recognize them simply fall back to instant jumps with no offset, which is exactly the behavior you had before, so these properties are safe to use without feature queries.

Conclusion

A handful of lines can noticeably improve how in-page navigation feels. Put scroll-padding-top on html to keep targets clear of your sticky header, add scroll-margin to specific targets that need extra room, and enable scroll-behavior: smooth inside a prefers-reduced-motion: no-preference query. Add a :target highlight and focusable headings, and readers will always know exactly where a link has taken them.

These are small properties, but they fix problems users run into constantly, and they do it without a single line of scroll-calculation JavaScript.

Tags :
Share :

Related Posts

A Complete Guide to CSS Container Queries

A Complete Guide to CSS Container Queries

For more than a decade, responsive design meant one thing: media queries. You asked the browser how wide the viewport was and adjusted your layout ac

Continue Reading
A Comprehensive Guide to Installing Next.js

A Comprehensive Guide to Installing Next.js

Next.js has emerged as a powerful framework for building React applications, offering features like server-side rendering, static site generation, an

Continue Reading
Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

CSS has evolved significantly, and modern tools like clamp(), min(), and max() are powerful game-changers in dynamic styling. If you’ve struggl

Continue Reading