Elastic Overscroll
Scroll past the top or bottom of a box and, instead of stopping dead, its contents stretch apart like a spring, giving less the further you pull. Let go and they spring back with a little wobble.
Are we still on for Saturday?
Yes! 10am at the park
Perfect
I’ll bring snacks
And the frisbee?
Obviously
See you there
Can’t wait
Weather looks good too
Sunny all day
How to use it
- Paste the snippet just before
</head>. - Add
data-elastic-overscrollto a scrolling box (a fixed height andoverflow: auto). - Its children are the pieces that spread apart, so put the items straight inside it.
Options
Add any of these attributes to the same element.
data-stretch | The most it gives, in px. Default 110. |
data-autoplay | Scrolls to each end and tugs past it every so many ms while in view. Default 2600. A real scroll pauses it. |
It works with touch, the mouse wheel or trackpad, and dragging with a mouse. With a wheel it stretches briefly, then lets the page carry on scrolling.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Elastic Overscroll: scroll past the top or bottom of a box and, instead of
// stopping dead, its contents stretch apart like a spring, with more give the
// further you pull. Let go and they spring back with a little wobble.
// Usage: <div data-elastic-overscroll style="height:300px;overflow:auto">…items…</div>
// The element is the scrolling box; its children are the pieces that
// spread apart.
// Optional: data-stretch (how far it gives, in px at most, default 110),
// data-autoplay (scrolls to each end and tugs past it every so many ms while
// in view, default 2600; a real scroll pauses it).
// Works with touch, the mouse wheel or trackpad, and dragging with a mouse.
var DEFAULTS = { stretch: 110, autoplay: 2600 };
var css =
'.eos>*{will-change:transform}' +
'.eos-grab{cursor:grab}' +
'.eos-grabbing,.eos-grabbing *{cursor:grabbing;user-select:none;-webkit-user-select:none}' +
'.eos-finger{position:absolute;left:0;top:0;width:34px;height:34px;margin:-17px 0 0 -17px;border-radius:50%;' +
'background:rgba(30,14,60,.16);border:2px solid rgba(30,14,60,.32);pointer-events:none;opacity:0;z-index:50}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function setup(el) {
var d = el.dataset;
var MAX = +d.stretch || DEFAULTS.stretch;
var thumb = !!el.closest('[aria-hidden="true"]');
el.classList.add('eos');
if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
var pieces = Array.from(el.children);
var over = 0, raw = 0, springing = null, userAt = -1e9;
function atTop() { return el.scrollTop <= 0; }
function atBottom() { return el.scrollTop + el.clientHeight >= el.scrollHeight - 1; }
// Resistance: easy at first, stiffer and stiffer, never past MAX.
function rubber(r) { var a = Math.abs(r); return Math.sign(r) * MAX * (1 - 1 / (a / (MAX * 1.6) + 1)); }
// Spread the pieces: the end you pull from gives least, the far side most,
// so the gaps between them open up like a stretched spring.
function draw(o) {
over = o;
if (springing) { springing.pause(); springing = null; }
var top = el.scrollTop, h = el.clientHeight || 1;
pieces.forEach(function (n) {
var t = Math.max(0, Math.min(1, (n.offsetTop - top + n.offsetHeight / 2) / h));
var w = o > 0 ? 0.35 + 0.65 * t : 0.35 + 0.65 * (1 - t);
anime.utils.set(n, { y: o * w });
});
}
function springBack() {
raw = 0;
if (!over) return;
var from = over > 0 ? 'first' : 'last';
over = 0;
springing = anime.animate(pieces, {
y: 0,
duration: reduced() ? 0 : 900,
ease: 'outElastic(1, .55)',
delay: reduced() ? 0 : anime.stagger(18, { from: from }),
});
}
// Moving by dy (positive = content down, as when dragging down at the top).
// Returns true when the move was taken as overscroll.
function push(dy) {
if (raw !== 0 || (dy > 0 && atTop()) || (dy < 0 && atBottom())) {
var next = raw + dy;
if (raw !== 0 && Math.sign(next) !== Math.sign(raw)) next = 0;
raw = next;
draw(rubber(raw));
return true;
}
return false;
}
if (!thumb) {
// Wheel and trackpad: overscroll briefly, then let the page carry on.
var wheelTimer = 0, spent = 0;
el.addEventListener('wheel', function (e) {
if (e.ctrlKey) return;
userAt = performance.now();
var dy = -(e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY);
clearTimeout(wheelTimer);
wheelTimer = setTimeout(function () { spent = 0; springBack(); }, 140);
if (spent > MAX * 3) return;
if (push(dy * 0.6)) { spent += Math.abs(dy); e.preventDefault(); }
}, { passive: false });
// Touch: native scrolling until an end, then stretch.
var lastY = null;
el.addEventListener('touchstart', function (e) { lastY = e.touches[0].clientY; userAt = performance.now(); }, { passive: true });
el.addEventListener('touchmove', function (e) {
if (lastY === null) return;
var y = e.touches[0].clientY, dy = y - lastY;
lastY = y;
userAt = performance.now();
if (push(dy) && e.cancelable) e.preventDefault();
}, { passive: false });
el.addEventListener('touchend', function () { lastY = null; springBack(); });
el.addEventListener('touchcancel', function () { lastY = null; springBack(); });
// Mouse: drag the content like a phone.
var mouseY = null;
el.classList.add('eos-grab');
el.addEventListener('pointerdown', function (e) {
if (e.pointerType !== 'mouse' || e.button || e.target.closest('a,button,input,select,textarea')) return;
mouseY = e.clientY;
userAt = performance.now();
el.setPointerCapture(e.pointerId);
el.classList.add('eos-grabbing');
});
el.addEventListener('pointermove', function (e) {
if (mouseY === null) return;
var dy = e.clientY - mouseY;
mouseY = e.clientY;
userAt = performance.now();
if (!push(dy)) el.scrollTop -= dy;
});
function mouseUp() {
if (mouseY === null) return;
mouseY = null;
el.classList.remove('eos-grabbing');
springBack();
}
el.addEventListener('pointerup', mouseUp);
el.addEventListener('pointercancel', mouseUp);
}
if (d.autoplay === undefined || d.autoplay === 'false' || reduced()) return;
var every = +d.autoplay || DEFAULTS.autoplay;
var finger = document.createElement('span');
finger.className = 'eos-finger';
finger.setAttribute('aria-hidden', 'true');
el.appendChild(finger);
var visible = false, timer = 0, toBottom = true;
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
clearTimeout(timer);
if (visible) timer = setTimeout(play, 500);
}).observe(el);
// Glide to one end, then tug past it and let go.
function play() {
if (!visible) return;
if (performance.now() - userAt < 5000 || raw) { timer = setTimeout(play, 1000); return; }
var end = toBottom ? el.scrollHeight - el.clientHeight : 0;
var dir = toBottom ? -1 : 1;
toBottom = !toBottom;
var sc = { v: el.scrollTop };
anime.animate(sc, {
v: end,
duration: Math.abs(end - sc.v) > 4 ? 700 : 0,
ease: 'inOut(2)',
onUpdate: function () { el.scrollTop = sc.v; },
onComplete: function () {
var w = el.clientWidth, h = el.clientHeight;
var fy = el.scrollTop + (dir > 0 ? h * 0.3 : h * 0.7);
var p = { d: 0 };
anime.utils.set(finger, { x: w * 0.62, y: fy, scale: 1.3 });
anime.animate(finger, { opacity: 1, scale: 1, duration: 160, ease: 'out(2)' });
setTimeout(function () {
var last = 0;
anime.animate(p, {
d: dir * MAX * 1.5,
duration: 520,
ease: 'out(2)',
onUpdate: function () {
push(p.d - last);
last = p.d;
anime.utils.set(finger, { y: fy + over * 0.7 });
},
onComplete: function () {
setTimeout(function () {
springBack();
anime.animate(finger, { opacity: 0, scale: 0.7, duration: 240, ease: 'out(2)' });
timer = setTimeout(play, every);
}, 140);
},
});
}, 180);
},
});
}
}
function init() {
document.querySelectorAll('[data-elastic-overscroll]').forEach(function (el) {
if (el._eos) return;
el._eos = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-elastic-overscroll style="height: 320px; overflow: auto">
<p>Are we still on for Saturday?</p>
<p>Yes! 10am at the park</p>
<p>Perfect</p>
</div>