The whole feature is one line. Put this in your stylesheet and every in-page anchor jump animates instead of snapping:
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:
/* 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:
: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:
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:
@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:
@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
- Respecting users with prefers-reduced-motion: the accessibility media query that gates smooth scroll, and the rest of your animations, for motion-sensitive users.
- The CSS :has() parent selector: another modern, JavaScript-killing CSS feature that is now Baseline and production-safe.
- Doing math in CSS with calc(): pair it with scroll-margin-top to derive a header offset from your spacing variables instead of hardcoding a pixel value.
Sources
Authoritative references this article was fact-checked against.
- scroll-behavior - CSS, MDN Web Docsdeveloper.mozilla.org
- scroll-margin-top - CSS, MDN Web Docsdeveloper.mozilla.org
- Element.scrollIntoView() - MDN Web Docsdeveloper.mozilla.org
- CSS scroll-behavior - Can I use browser support tablescaniuse.com





