← Gestures

Momentum Carousel

Drag or fling a row of slides. It keeps the momentum of your flick, gliding past several slides if you throw it hard, then eases to a stop with the nearest slide centred. The ends stretch like rubber, and slides away from the centre shrink and dim a little.

01Peaks
02Coast
03Desert
04Forest
05Lakes
06Canyon
07Tundra

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-momentum-carousel to a container whose children are the slides.
  3. Give the slides a width, such as 70%. Set the gap with the --mcr-gap CSS variable.

Options

Add any of these attributes to the same element.

data-dotsAdds a row of dots underneath that show and pick the slide.
data-depthHow much slides away from the centre shrink, from 0 to 0.5. Default 0.12.
data-autoplayA pretend finger flings it every so many ms while in view. Default 2200. A real drag pauses it.

It fires slidechange with detail.index. With focus, the arrow keys move it, and animacious.momentumCarousel.go(el, 2) moves it from code.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Momentum Carousel: drag or fling a row of slides. It keeps the momentum of
    // your flick, gliding past several slides if you throw it hard, then eases
    // to a stop with the nearest slide centred. The ends stretch like rubber, and
    // slides away from the centre shrink and dim a little.
    // Usage:
    //   <div data-momentum-carousel>
    //     <div>Slide 1</div>
    //     <div>Slide 2</div>
    //   </div>
    //   Give the slides a width (say 70%); the gap between them comes from the
    //   --mcr-gap CSS variable (default 16px).
    // Optional: data-dots (adds a row of dots underneath), data-depth (how much
    // slides away from the centre shrink, 0 to 0.5, default 0.12),
    // data-autoplay (a pretend finger flings it every so many ms while in view,
    // default 2200; a real drag pauses it).
    // Events: fires "slidechange" with detail { index }. Arrow keys move it
    // when it has focus. Script use: animacious.momentumCarousel.go(el, index).
    var DEFAULTS = { depth: 0.12, autoplay: 2200 };

    var css =
      '.mcr{position:relative;overflow:hidden;touch-action:pan-y;user-select:none;-webkit-user-select:none;cursor:grab;outline-offset:4px}' +
      '.mcr.mcr-held{cursor:grabbing}' +
      '.mcr-track{display:flex;gap:var(--mcr-gap,16px);will-change:transform}' +
      '.mcr-track>*{flex:none;will-change:transform}' +
      '.mcr-track img{pointer-events:none}' +
      '.mcr-dots{display:flex;justify-content:center;gap:8px;margin-top:14px}' +
      '.mcr-dot{width:8px;height:8px;padding:0;border:0;color:inherit;border-radius:99px;background:currentColor;opacity:.25;cursor:pointer;transition:width .3s,opacity .3s}' +
      '.mcr-dot.mcr-on{width:22px;opacity:.9}' +
      '.mcr-finger{position:absolute;left:0;top:0;width:34px;height:34px;margin:-17px 0 0 -17px;border-radius:50%;' +
      'background:rgba(255,255,255,.4);border:2px solid rgba(255,255,255,.9);box-shadow:0 2px 10px rgba(0,0,0,.2);pointer-events:none;opacity:0;z-index:5}';
    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 depth = d.depth !== undefined ? Math.max(0, Math.min(0.5, +d.depth)) : DEFAULTS.depth;
      var thumb = !!el.closest('[aria-hidden="true"]');
      var slides = Array.from(el.children);
      if (!slides.length) return;
      el.classList.add('mcr');
      var track = document.createElement('div');
      track.className = 'mcr-track';
      slides.forEach(function (n) { track.appendChild(n); });
      el.appendChild(track);

      var dots = [];
      if (d.dots !== undefined && d.dots !== 'false') {
        var row = document.createElement('div');
        row.className = 'mcr-dots';
        slides.forEach(function (n, i) {
          var b = document.createElement(thumb ? 'span' : 'button');
          b.className = 'mcr-dot';
          if (!thumb) {
            b.type = 'button';
            b.setAttribute('aria-label', 'Slide ' + (i + 1));
            b.addEventListener('click', function () { userAt = performance.now(); go(i); });
          }
          row.appendChild(b);
          dots.push(b);
        });
        el.parentNode.insertBefore(row, el.nextSibling);
      }

      var x = 0, index = 0, snaps = [], anim = null, userAt = -1e9;

      function measure() {
        var w = el.clientWidth;
        snaps = slides.map(function (n) { return -(n.offsetLeft + n.offsetWidth / 2 - w / 2); });
      }

      function render(v) {
        x = v;
        anime.utils.set(track, { x: v });
        var w = el.clientWidth || 1;
        slides.forEach(function (n, i) {
          var off = Math.min(1, Math.abs(snaps[i] - v) / (n.offsetWidth || w));
          anime.utils.set(n, { scale: 1 - off * depth, opacity: 1 - off * depth * 3 });
        });
      }

      function nearest(v) {
        var best = 0;
        snaps.forEach(function (s, i) { if (Math.abs(s - v) < Math.abs(snaps[best] - v)) best = i; });
        return best;
      }

      function mark(i) {
        if (i === index && dots.length && dots[i].classList.contains('mcr-on')) return;
        var changed = i !== index;
        index = i;
        dots.forEach(function (b, k) {
          b.classList.toggle('mcr-on', k === i);
          if (b.tagName === 'BUTTON') b.setAttribute('aria-current', k === i ? 'true' : 'false');
        });
        slides.forEach(function (n, k) { n.setAttribute('aria-hidden', k === i ? 'false' : 'true'); });
        if (changed) el.dispatchEvent(new CustomEvent('slidechange', { detail: { index: i } }));
      }

      // Glide to a slide; longer trips take a little longer.
      function go(i, ms) {
        i = Math.max(0, Math.min(slides.length - 1, i));
        if (anim) anim.pause();
        var o = { v: x };
        var dist = Math.abs(snaps[i] - x);
        anim = anime.animate(o, {
          v: snaps[i],
          duration: reduced() ? 0 : (ms || Math.max(380, Math.min(1100, 300 + dist * 0.9))),
          ease: 'out(3)',
          onUpdate: function () { render(o.v); },
        });
        mark(i);
      }

      // Dragging, with rubber at the ends.
      var drag = null;
      function grab(px) {
        if (anim) anim.pause();
        measure();
        drag = { sx: px, x0: x, moved: 0, samples: [{ t: performance.now(), x: px }] };
        el.classList.add('mcr-held');
      }
      function moveTo(px) {
        if (!drag) return;
        var v = drag.x0 + px - drag.sx;
        var lo = snaps[snaps.length - 1], hi = snaps[0];
        if (v > hi) v = hi + (v - hi) * 0.35;
        if (v < lo) v = lo + (v - lo) * 0.35;
        drag.moved = Math.max(drag.moved, Math.abs(px - drag.sx));
        render(v);
        var now = performance.now();
        drag.samples.push({ t: now, x: px });
        while (drag.samples.length > 2 && now - drag.samples[0].t > 100) drag.samples.shift();
      }
      function release(vel) {
        if (!drag) return;
        var sm = drag.samples, a = sm[0], b = sm[sm.length - 1];
        if (vel === undefined) vel = performance.now() - b.t < 80 && b.t > a.t ? (b.x - a.x) / ((b.t - a.t) / 1000) : 0;
        var moved = drag.moved;
        drag = null;
        el.classList.remove('mcr-held');
        // Where the flick would carry it, then the slide nearest to that.
        var target = nearest(x + vel * 0.32);
        if (target === index && moved > 30 && Math.abs(vel) > 250) target += vel < 0 ? 1 : -1;
        go(target);
        return moved;
      }

      if (!thumb) {
        if (!el.hasAttribute('tabindex')) el.tabIndex = 0;
        el.setAttribute('role', 'region');
        el.setAttribute('aria-roledescription', 'carousel');
        var moved = 0, active = null;
        el.addEventListener('pointerdown', function (e) {
          if (e.button) return;
          userAt = performance.now();
          active = e.pointerId;
          grab(e.clientX);
          moved = 0;
        });
        el.addEventListener('pointermove', function (e) {
          if (!drag || e.pointerId !== active) return;
          userAt = performance.now();
          if (Math.abs(e.clientX - drag.sx) > 6 && !el.hasPointerCapture(e.pointerId)) el.setPointerCapture(e.pointerId);
          moveTo(e.clientX);
        });
        el.addEventListener('pointerup', function () { moved = release() || 0; });
        el.addEventListener('pointercancel', function () { release(0); });
        // A drag is not a click on whatever is under the finger.
        el.addEventListener('click', function (e) { if (moved > 6) { e.preventDefault(); e.stopPropagation(); } }, true);
        el.addEventListener('dragstart', function (e) { e.preventDefault(); });
        el.addEventListener('keydown', function (e) {
          if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
          e.preventDefault();
          userAt = performance.now();
          go(index + (e.key === 'ArrowRight' ? 1 : -1));
        });
      }

      new ResizeObserver(function () { measure(); if (anim) anim.pause(); render(snaps[index]); }).observe(el);
      measure();
      render(snaps[0]);
      index = -1;
      mark(0);
      el._mcr = { go: function (i) { go(i); } };

      if (d.autoplay === undefined || d.autoplay === 'false' || reduced()) return;
      var every = +d.autoplay || DEFAULTS.autoplay;
      var finger = document.createElement('span');
      finger.className = 'mcr-finger';
      finger.setAttribute('aria-hidden', 'true');
      el.appendChild(finger);
      var visible = false, timer = 0, dir = 1;
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        clearTimeout(timer);
        if (visible) timer = setTimeout(play, 600);
      }).observe(el);

      // Flick by one or two slides, turning round at the ends.
      function play() {
        if (!visible) return;
        if (performance.now() - userAt < 5000 || drag) { timer = setTimeout(play, 1000); return; }
        var n = slides.length;
        var hop = Math.random() < 0.45 ? 2 : 1;
        if (index + dir * hop > n - 1 || index + dir * hop < 0) dir = -dir;
        hop = Math.max(1, Math.min(hop, dir > 0 ? n - 1 - index : index));
        if (n < 2) return;
        var w = el.clientWidth, h = el.clientHeight;
        var sx = w / 2 + dir * w * 0.18, sy = h * 0.55;
        var p = { x: sx };
        var swipe = -dir * Math.min(110, w * 0.3);
        anime.utils.set(finger, { x: sx, y: sy, scale: 1.3 });
        anime.animate(finger, { opacity: 1, scale: 1, duration: 160, ease: 'out(2)' });
        setTimeout(function () {
          if (drag) { timer = setTimeout(play, 1000); return; }
          grab(sx);
          anime.animate(p, {
            x: sx + swipe,
            duration: 240,
            ease: 'in(2)',
            onUpdate: function () {
              moveTo(p.x);
              anime.utils.set(finger, { x: p.x });
            },
            onComplete: function () {
              // The speed that carries it about `hop` slides.
              var target = snaps[Math.max(0, Math.min(n - 1, index + dir * hop))];
              release((target - x) / 0.32);
              anime.animate(finger, { x: p.x + swipe * 0.3, opacity: 0, scale: 0.7, duration: 260, ease: 'out(2)' });
              timer = setTimeout(play, every);
            },
          });
        }, 180);
      }
    }

    function init() {
      document.querySelectorAll('[data-momentum-carousel]').forEach(function (el) {
        if (el._mcr) return;
        setup(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.momentumCarousel = { go: function (el, i) { if (el._mcr) el._mcr.go(i); } };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-momentum-carousel data-dots style="--mcr-gap: 16px">
  <div class="slide" style="width: 70%">Peaks</div>
  <div class="slide" style="width: 70%">Coast</div>
  <div class="slide" style="width: 70%">Desert</div>
</div>