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.
How to use it
- Paste the snippet just before
</head>. - Add
data-momentum-carouselto a container whose children are the slides. - Give the slides a width, such as
70%. Set the gap with the--mcr-gapCSS variable.
Options
Add any of these attributes to the same element.
data-dots | Adds a row of dots underneath that show and pick the slide. |
data-depth | How much slides away from the centre shrink, from 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. |
It fires slidechange with detail.index. With focus, the arrow keys move it, and animacious.momentumCarousel.go(el, 2) moves it from code.
<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><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>