TechEarl

Smooth Scrolling in CSS with scroll-behavior (No JavaScript)

Smooth scroll without JavaScript: one line, html { scroll-behavior: smooth }, animates every anchor jump. Plus the scroll-margin-top fix for sticky headers, the scrollIntoView companion, and the prefers-reduced-motion guard.

Ishan Karunaratne⏱️ 7 min readUpdated
Share thisCopied
Add smooth scrolling in CSS with one line of scroll-behavior, offset anchor targets under a sticky header with scroll-margin-top, and respect prefers-reduced-motion.

The whole feature is one line. Put this in your stylesheet and every in-page anchor jump animates instead of snapping:

css
html {
  scroll-behavior: smooth;
}

That is it. Click a link to #section-3, or hit the back button to a hash, and the page glides there. No jQuery animate(), no scroll-easing library, no event.preventDefault() on every anchor. The scroll-behavior property is Baseline Widely Available: Safari was the last engine to ship it (15.4, March 2022), and it crossed the Baseline "widely available" threshold in September 2024. Every current browser supports it, so the plugins people used to reach for (the old jQuery and MooTools smooth-scroll scripts) are now dead weight you can delete.

What the one line actually controls

scroll-behavior changes how a scroll is animated, not whether scrolling happens. It applies to programmatic and navigation scrolls: clicking an href="#id" link, a hash change in the URL, and the JavaScript scroll APIs. It deliberately does not affect the user dragging the scrollbar or spinning the mouse wheel, that stays as responsive as ever. So you get animated jumps for "skip to section" links without making the whole page feel laggy under the user's own hand.

The values are just auto (the default, instant jump) and smooth (animated). The easing curve and duration are picked by the browser; there is no CSS knob to tune them, which is the one real limitation. If you need a specific 600ms ease-out, that is still a JavaScript job.

Per-element vs global

Setting it on html covers the document, which is what you want for a typical page of anchor links. But scroll-behavior works on any scroll container, so you can animate just one independently scrolling box and leave the rest of the page alone:

css
/* Smooth only inside this panel's own scrollbar */
.chat-log {
  overflow-y: auto;
  scroll-behavior: smooth;
}

When a JS call scrolls .chat-log to the bottom on a new message, it eases down instead of teleporting, while the main document keeps its default instant behavior.

The sticky-header gotcha: scroll-margin-top

Here is the bug everyone hits. You add a sticky header, you add smooth scroll, and now every anchor jump lands with the target heading hidden behind the header. The browser scrolls the target to the very top of the viewport, and your fixed bar is sitting on top of it.

The fix is scroll-margin-top on the scroll targets. It tells the browser to stop short by that much when snapping an element into view:

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

/* Offset any element an anchor link can target */
:target {
  scroll-margin-top: var(--header-height);
}

/* Or scope it to the headings you actually link to */
h2[id],
h3[id] {
  scroll-margin-top: 4.5rem;
}

scroll-margin-top is the clean, modern answer. It replaces the old hacks: invisible padding, a negative-margin pseudo-element, or an empty offset anchor <span> before each heading. Set the value a little larger than the header so the heading clears it with breathing room. It is well supported across current browsers, and the shorthand scroll-margin covers all four sides if you ever scroll horizontally too.

JS-triggered scrolls: scrollIntoView

When the scroll comes from your own code rather than an anchor click (scrolling to a freshly added item, a validation error, a search result), the JavaScript equivalent is scrollIntoView with the behavior option:

javascript
document.querySelector("#section-3").scrollIntoView({
  behavior: "smooth",
  block: "start",
});

Two things worth knowing. First, scrollIntoView respects scroll-margin-top too, so the sticky-header offset you set in CSS carries over for free. Second, if html already has scroll-behavior: smooth, even a plain scrollIntoView() with no options animates, because the property governs the element's programmatic scrolls. Passing behavior: "smooth" explicitly is the clearer choice when you do not want to depend on a global rule.

Respect prefers-reduced-motion (do not skip this)

Smooth scrolling is motion, and a long animated jump down a page can trigger nausea or dizziness for people with vestibular disorders. The fix is to gate the animation behind a media query, so users who have asked their OS for reduced motion get the instant jump instead:

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

Note the inversion. Rather than turning smooth scroll off for the reduced-motion crowd, default to instant and only opt in to smooth when the user has expressed no-preference. That way the safe behavior is the fallback, and a browser that never reports a preference still gets a sane default. This is the correct accessibility pattern for any motion you add, and there is a fuller treatment in respecting prefers-reduced-motion.

Putting it together

A complete, production-safe smooth-scroll setup is three short rules:

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

:target {
  scroll-margin-top: 4.5rem; /* clear the sticky header */
}

That covers anchor links, the hash-on-load case, the sticky-header offset, and motion-sensitive users, with zero JavaScript and nothing to install. Reach for scrollIntoView({ behavior: "smooth" }) only where the scroll is something your code initiates.

FAQ

See also

Sources

Authoritative references this article was fact-checked against.

TagsCSSscroll-behaviorsmooth scrollscroll-margin-topscrollIntoViewprefers-reduced-motionaccessibility

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

How to sleep(), wait, and poll in JavaScript

JavaScript has no blocking sleep(). Here is the one-liner that actually works (await a Promise around setTimeout), Node's native timers/promises, a cancellable polling helper, and the setTimeout 4ms-clamp and Date.now vs performance.now gotchas.

How async Functions Really Work in JavaScript

What an async function actually is under the hood: it desugars to a generator plus a built-in runner. Plus async generators with for await...of, the AsyncFunction constructor, and why detecting async-ness is a trap.