← Gestures

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

  1. Paste the snippet just before </head>.
  2. Add data-swipe-pages to a box with a height, with one child per page.
  3. Give each page one inner wrapper: that’s the part that trails behind.

Options

Add any of these attributes to the same element.

data-dotsAdds a row of dots underneath that show and pick the page.
data-thresholdShare of the width to drag before it turns the page. Default 0.25.
data-parallaxHow far each page’s contents lag behind, from 0 to 0.5. Default 0.25.
data-autoplayA 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.

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