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

Yes, completely. html { scroll-behavior: smooth } animates every in-page anchor link and hash navigation with no script at all. JavaScript only enters the picture when you want to trigger a scroll yourself, in which case you use scrollIntoView({ behavior: 'smooth' }).

The browser scrolls the target to the very top of the viewport, and your fixed header sits over it. Add scroll-margin-top to the scroll targets (a value a little larger than the header height) so the browser stops short and the heading clears the bar.

Not in CSS. The duration and easing curve are chosen by the browser, and there is no property to override them. If you need a specific speed or curve, animate the scroll in JavaScript instead.

Yes. It reached Baseline Widely Available in September 2024 (Safari 15.4 in March 2022 was the last engine to ship it), and it is supported in every current browser. Older browsers that do not understand it simply fall back to an instant jump, which is a graceful degradation, so there is no reason to hold back.

No. The one CSS line replaces the old jQuery and MooTools smooth-scroll plugins for anchor navigation. Delete them. JavaScript is only worth keeping for scrolls your own code initiates or when you genuinely need a custom duration the browser will not give you.

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

Software Systems Architect · Senior Software Engineer · Engineering Leadership

Software systems architect and senior software engineer with more than two decades designing, building, and running production software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Now a CTO, though what I write here is drawn from the full arc of that work, across architecture, engineering, and operations, not any single job.

Keep reading

Related posts

JavaScript has no blocking sleep. Await a Promise around setTimeout, use Node's native timers/promises, poll for a condition with AbortSignal, and know the setTimeout 4ms clamp and Date.now vs performance.now difference.

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 work in JavaScript: they desugar to a generator plus a runner. Plus async generators with for await...of, the AsyncFunction constructor, and why you should not detect async-ness.

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.