Smooth Scroll
Each turn of the mouse wheel sets where the page should end up, and the page eases there, so jerky wheel steps become one long, soft glide that settles gently. Touch, keyboard and scrollbar scrolling are left exactly as they are.
01Ease inEach wheel step sets a target.
02GlideThe box eases towards it.
03MomentumQuick flicks add up.
04SettleIt slows to a gentle stop.
05AgainBack up the same way.
06Native feelTouch and keys stay as they are.
Try your mouse wheel inside the box
How to use it
- Paste the snippet just before
</head>. - Add
data-smooth-scrollto<body>for the whole page, or to any box withoverflow-y: auto.
Options
Add any of these attributes to the same element.
data-ease | How much of the remaining distance it covers each frame, from 0.02 to 1. Lower is floatier. Default 0.1. |
data-multiplier | Wheel speed. Default 1. |
data-autoplay | A scrolling box flicks itself down and back up while in view, until someone scrolls it. |
Wheels over an inner box that can still scroll, and pinch-zoom, are left alone. With reduced motion scrolling stays native. animacious.smoothScrollTo(el, y) glides to a position (pass document.body for the page). Don’t combine it with CSS scroll-behavior: smooth on the same scroller.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Smooth Scroll: momentum smooth scrolling. Each turn of the mouse wheel
// sets where the page should end up, and the page eases there, so steps
// become one long glide. Touch, keyboard and scrollbar scrolling stay native.
// Usage: <body data-smooth-scroll> (the whole page)
// <div data-smooth-scroll style="overflow-y:auto">…</div> (one scrolling box)
// Optional: data-ease (how much of the remaining distance it covers each
// frame, 0.02 to 1, default 0.1: lower is floatier), data-multiplier (wheel
// speed, default 1), data-autoplay (a scrolling box flicks itself down and
// back up while in view, until someone scrolls it).
// Wheels over a box that can still scroll itself, and pinch-zoom, are left alone.
// Script use: animacious.smoothScrollTo(el, y) glides a set-up element (or
// document.body for the page) to y.
var DEFAULTS = { ease: 0.1, multiplier: 1 };
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function num(v, fallback) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : fallback; }
function canScroll(node, dy, stop) {
for (; node && node !== stop && node.nodeType === 1; node = node.parentElement) {
var o = getComputedStyle(node).overflowY;
if ((o === 'auto' || o === 'scroll') && node.scrollHeight > node.clientHeight + 1) {
if (dy > 0 && node.scrollTop + node.clientHeight < node.scrollHeight - 1) return true;
if (dy < 0 && node.scrollTop > 0) return true;
}
}
return false;
}
function setup(el) {
var d = el.dataset;
var ease = Math.max(0.02, Math.min(1, num(d.ease, DEFAULTS.ease)));
var mult = num(d.multiplier, DEFAULTS.multiplier);
var page = el === document.body || el === document.documentElement;
var root = document.scrollingElement || document.documentElement;
var sc = page ? root : el;
function pos() { return page ? window.pageYOffset : el.scrollTop; }
function max() { return sc.scrollHeight - (page ? window.innerHeight : el.clientHeight); }
function set(y) {
if (page) window.scrollTo(0, y);
else el.scrollTop = y;
}
var current = pos(), target = current, running = false, last = current;
function tick() {
current += (target - current) * ease;
if (Math.abs(target - current) < 0.5) current = target;
set(current);
last = pos();
if (current !== target) requestAnimationFrame(tick);
else running = false;
}
function glideTo(y) {
target = Math.max(0, Math.min(max(), y));
if (!running) {
current = pos();
running = true;
requestAnimationFrame(tick);
}
}
(page ? window : el).addEventListener('wheel', function (e) {
if (e.ctrlKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
var dy = e.deltaY;
if (e.deltaMode === 1) dy *= 16;
else if (e.deltaMode === 2) dy *= page ? window.innerHeight : el.clientHeight;
if (canScroll(e.target, dy, page ? null : el)) return; // let inner boxes scroll themselves
var base = running ? target : pos();
var next = Math.max(0, Math.min(max(), base + dy * mult));
if (next === base && !running) return; // at the end: let the wheel pass on
e.preventDefault();
el._smscUser = true;
glideTo(next);
}, { passive: false });
// Scrolling by other means (keys, scrollbar, touch) takes over from the glide.
(page ? window : el).addEventListener('scroll', function () {
var y = pos();
if (running && Math.abs(y - last) > 2) {
target = current = y;
running = false;
} else if (!running) {
target = current = y;
}
}, { passive: true });
el._smscTo = glideTo;
}
function autoplay(el) {
var timer = null, down = true;
var stop = function () {
el._smscUser = true;
clearInterval(timer);
};
el.addEventListener('pointerdown', stop);
el.addEventListener('touchstart', stop, { passive: true });
el.addEventListener('keydown', stop);
// A few wheel-sized flicks, then the same back up.
function flick() {
if (el._smscUser) return stop();
var room = el.scrollHeight - el.clientHeight;
if (room <= 0) return;
var step = Math.max(60, el.clientHeight * 0.45);
if (down && el.scrollTop >= room - 1) down = false;
else if (!down && el.scrollTop <= 1) down = true;
el._smscTo(el.scrollTop + (down ? step : -step * 1.6));
}
new IntersectionObserver(function (es) {
clearInterval(timer);
if (es[0].isIntersecting && !el._smscUser) timer = setInterval(flick, 900);
}).observe(el);
}
function init() {
document.querySelectorAll('[data-smooth-scroll]').forEach(function (el) {
if (el._smsc) return;
el._smsc = true;
if (reduced()) return; // no motion: plain native scrolling
setup(el);
if ('autoplay' in el.dataset && el !== document.body) autoplay(el);
});
}
window.animacious = window.animacious || {};
window.animacious.smoothScrollTo = function (el, y) {
if (el && el._smscTo) el._smscTo(y);
else if (el === document.body || el === document.documentElement) window.scrollTo(0, y);
else if (el) el.scrollTop = y;
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><body data-smooth-scroll>
…
</body>
<!-- or one scrolling box: -->
<div data-smooth-scroll style="height: 400px; overflow-y: auto">…</div>