← Gestures

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

  1. Paste the snippet just before </head>.
  2. Add data-elastic-overscroll to a scrolling box (a fixed height and overflow: auto).
  3. 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-stretchThe most it gives, in px. Default 110.
data-autoplayScrolls 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.

1 · Snippet
<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>
2 · Your HTML
<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>