Swipe Pages
Full-width pages sit side by side and follow your finger as you drag, each page’s contents trailing a little behind for depth. Let go past a quarter of the way, or flick, and it settles on the next or previous page; otherwise it slides back. The ends stretch like rubber.
01WelcomeSwipe to have a look round
02PlanLay out your week in a minute
03TrackSee how far you’ve come
04DoneThat’s it. Off you go!
How to use it
- Paste the snippet just before
</head>. - Add
data-swipe-pagesto a box with a height, with one child per page. - Give each page one inner wrapper: that’s the part that trails behind.
Options
Add any of these attributes to the same element.
data-dots | Adds a row of dots underneath that show and pick the page. |
data-threshold | Share of the width to drag before it turns the page. Default 0.25. |
data-parallax | How far each page’s contents lag behind, from 0 to 0.5. Default 0.25. |
data-autoplay | A pretend finger swipes every so many ms while in view, there and back. Default 2400. A real swipe pauses it. |
Up-and-down swipes still scroll the page. It fires pagechange with detail.index; with focus, the arrow keys turn pages, and animacious.swipePages.go(el, 2) turns them from code.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Swipe Pages: full-width pages sit side by side. Drag sideways and they
// follow your finger, the page's contents trailing a little behind for
// depth. Let go past a point, or flick, and it settles on the next or
// previous page; let go early and it slides back. The ends stretch like
// rubber.
// Usage:
// <div data-swipe-pages>
// <section>Page one</section>
// <section>Page two</section>
// </div>
// Optional: data-dots (a row of dots underneath that show and pick the
// page), data-threshold (share of the width to drag before it turns,
// default 0.25), data-parallax (how far the contents lag, 0 to 0.5,
// default 0.25), data-autoplay (a pretend finger swipes every so many ms
// while in view, default 2400; a real swipe pauses it).
// Events: "pagechange" with detail.index.
// Script use: animacious.swipePages.go(el, 2)
var DEFAULTS = { threshold: 0.25, parallax: 0.25, autoplay: 2400 };
var css =
'.swp{position:relative;overflow:hidden;touch-action:pan-y;user-select:none;-webkit-user-select:none;cursor:grab}' +
'.swp.swp-held{cursor:grabbing}' +
'.swp-track{display:flex;height:100%;will-change:transform}' +
'.swp-track>*{flex:0 0 100%;min-width:0;box-sizing:border-box;overflow:hidden}' +
'.swp-dots{display:flex;justify-content:center;gap:7px;margin-top:12px}' +
'.swp-dot{width:7px;height:7px;padding:0;border:0;color:inherit;border-radius:99px;background:currentColor;opacity:.25;cursor:pointer;transition:width .3s,opacity .3s}' +
'.swp-dot.swp-on{width:20px;opacity:.9}' +
'.swp-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 band(d, dim) { return (1 - 1 / (d * 0.55 / dim + 1)) * dim; }
function setup(el) {
var d = el.dataset;
var pages = [].slice.call(el.children);
if (!pages.length) return;
var limit = +d.threshold || DEFAULTS.threshold;
var lag = d.parallax !== undefined ? +d.parallax : DEFAULTS.parallax;
var thumb = !!el.closest('[aria-hidden="true"]');
el.classList.add('swp');
var track = document.createElement('div');
track.className = 'swp-track';
pages.forEach(function (p) { track.appendChild(p); });
el.appendChild(track);
var n = pages.length;
if (!thumb) {
el.tabIndex = 0;
el.setAttribute('role', 'region');
el.setAttribute('aria-roledescription', 'carousel');
pages.forEach(function (p, i) {
p.setAttribute('role', 'group');
p.setAttribute('aria-roledescription', 'page');
p.setAttribute('aria-label', (i + 1) + ' of ' + n);
});
}
var dots = [];
if (d.dots !== undefined) {
var row = document.createElement('div');
row.className = 'swp-dots';
pages.forEach(function (p, i) {
var b = document.createElement(thumb ? 'span' : 'button');
b.className = 'swp-dot';
if (!thumb) { b.type = 'button'; b.setAttribute('aria-label', 'Page ' + (i + 1)); b.addEventListener('click', function () { s.userAt = performance.now(); go(i); }); }
row.appendChild(b);
dots.push(b);
});
el.parentNode.insertBefore(row, el.nextSibling);
}
var s = el._swp = { i: 0, pos: 0, userAt: -1e9, anim: null };
var drag = null;
var W = function () { return el.clientWidth || 1; };
// pos is in pages: 1.5 is half way between the second and third.
function draw(pos) {
s.pos = pos;
var w = W();
track.style.transform = 'translate3d(' + (-pos * w) + 'px,0,0)';
pages.forEach(function (p, i) {
var off = i - pos;
if (Math.abs(off) > 1.2) return;
var inner = p.firstElementChild;
if (inner && lag) inner.style.transform = 'translate3d(' + (-off * w * lag) + 'px,0,0)';
p.style.opacity = 1 - Math.min(1, Math.abs(off)) * 0.35;
});
}
function mark() {
dots.forEach(function (b, i) {
b.classList.toggle('swp-on', i === s.i);
if (!thumb) b.setAttribute('aria-current', i === s.i ? 'true' : 'false');
});
pages.forEach(function (p, i) { if (!thumb) p.setAttribute('aria-hidden', i === s.i ? 'false' : 'true'); });
}
function go(i, ms) {
i = Math.max(0, Math.min(n - 1, i));
var changed = i !== s.i;
s.i = i;
mark();
if (s.anim) s.anim.pause();
var o = { p: s.pos };
s.anim = anime.animate(o, {
p: i,
duration: reduced() ? 0 : ms || 620,
ease: 'out(4)',
onUpdate: function () { draw(o.p); },
});
if (changed) el.dispatchEvent(new CustomEvent('pagechange', { detail: { index: i } }));
}
s.go = go;
function grab(x) {
if (s.anim) { s.anim.pause(); s.anim = null; }
drag = { x0: x, p0: s.pos, samples: [{ t: performance.now(), x: x }] };
el.classList.add('swp-held');
}
function moveTo(x) {
if (!drag) return;
var w = W();
var p = drag.p0 - (x - drag.x0) / w;
if (p < 0) p = -band(-p * w, w) / w;
if (p > n - 1) p = n - 1 + band((p - n + 1) * w, w) / w;
draw(p);
var now = performance.now();
drag.samples.push({ t: now, x: x });
while (drag.samples.length > 2 && now - drag.samples[0].t > 100) drag.samples.shift();
}
function release() {
if (!drag) return;
var sm = drag.samples, a = sm[0], b = sm[sm.length - 1];
var dt = (b.t - a.t) / 1000;
var v = dt > 0 && performance.now() - b.t < 80 ? (b.x - a.x) / dt : 0;
drag = null;
el.classList.remove('swp-held');
var delta = s.pos - s.i, to = s.i;
if (delta > limit || (v < -450 && delta > 0.03)) to = s.i + 1;
else if (delta < -limit || (v > 450 && delta < -0.03)) to = s.i - 1;
go(to, 520);
}
s.grab = grab; s.moveTo = moveTo; s.release = release;
if (!thumb) {
var pid = null, sx = 0, sy = 0, decided = false;
el.addEventListener('pointerdown', function (e) {
if (e.button || e.target.closest('a,button,input,select,textarea')) return;
pid = e.pointerId; sx = e.clientX; sy = e.clientY; decided = false;
s.userAt = performance.now();
hideFinger();
if (e.pointerType === 'mouse') { decided = true; el.setPointerCapture(pid); grab(sx); e.preventDefault(); }
});
el.addEventListener('pointermove', function (e) {
if (e.pointerId !== pid) return;
s.userAt = performance.now();
if (!decided) {
var dx = Math.abs(e.clientX - sx), dy = Math.abs(e.clientY - sy);
if (dx < 6 && dy < 6) return;
decided = true;
if (dy > dx) { pid = null; return; } // a vertical scroll: leave it to the page
el.setPointerCapture(pid);
grab(sx);
}
moveTo(e.clientX);
});
function end(e) { if (e.pointerId !== pid) return; pid = null; release(); }
el.addEventListener('pointerup', end);
el.addEventListener('pointercancel', end);
el.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { e.preventDefault(); s.userAt = performance.now(); go(s.i + 1); }
if (e.key === 'ArrowLeft') { e.preventDefault(); s.userAt = performance.now(); go(s.i - 1); }
});
}
mark();
draw(0);
new ResizeObserver(function () { if (!drag && !s.anim) draw(s.i); else if (!drag) draw(s.pos); }).observe(el);
if (d.autoplay === undefined || d.autoplay === 'false' || reduced()) return;
var every = +d.autoplay || DEFAULTS.autoplay;
var finger = document.createElement('span');
finger.className = 'swp-finger';
finger.setAttribute('aria-hidden', 'true');
el.appendChild(finger);
function hideFinger() { anime.animate(finger, { opacity: 0, duration: 200 }); }
var visible = false, timer = 0, dir = 1, step = 0;
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
clearTimeout(timer);
if (visible) timer = setTimeout(play, every * 0.6);
}).observe(el);
// Mostly real swipes; now and then a timid one that slides back.
function play() {
if (!visible) return;
if (performance.now() - s.userAt < 5000 || drag) { timer = setTimeout(play, 900); return; }
if (s.i + dir < 0 || s.i + dir > n - 1) dir = -dir;
var timid = step++ % 4 === 3;
var w = W(), h = el.clientHeight;
var x0 = dir > 0 ? w * 0.78 : w * 0.22, y = h * 0.62;
var reach = w * (timid ? 0.16 : 0.42) * -dir;
var p = { x: x0 };
anime.utils.set(finger, { x: x0, y: y });
anime.animate(finger, { opacity: [0, 1], scale: [1.3, 1], duration: 180, ease: 'out(2)' });
timer = setTimeout(function () {
if (performance.now() - s.userAt < 5000) { hideFinger(); timer = setTimeout(play, 900); return; }
grab(x0);
anime.animate(p, {
x: x0 + reach,
duration: timid ? 460 : 360,
ease: timid ? 'inOut(2)' : 'in(1.5)',
onUpdate: function () {
if (!drag) return;
moveTo(p.x);
anime.utils.set(finger, { x: p.x });
},
onComplete: function () {
setTimeout(function () {
release();
anime.animate(finger, { opacity: 0, duration: 220 });
timer = setTimeout(play, every);
}, timid ? 140 : 0);
},
});
}, 220);
}
}
function init() {
document.querySelectorAll('[data-swipe-pages]').forEach(function (el) {
if (el._swp) return;
setup(el);
});
}
window.animacious = window.animacious || {};
window.animacious.swipePages = { go: function (el, i) { if (el._swp) el._swp.go(i); } };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-swipe-pages data-dots style="height: 320px">
<section><div>Welcome</div></section>
<section><div>Plan</div></section>
<section><div>Track</div></section>
</div>