← Gestures

Swipe Cards

A stack of cards you swipe left or right. The top card follows your finger and tilts, a “Like” or “Nope” stamp fades in, and past a point (or with a quick flick) it flies off while the next card rises. Let go early and it springs back.

LisbonSun, tiles and trams
KyotoTemples and tea
ReykjavíkHot springs and lights
OaxacaMarkets and mezcal

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-swipe-cards to a container and give it a size.
  3. Put the cards inside as its children. The first one is on top.
  4. Listen for the swipe event to find out which way each card went.

Options

Add any of these attributes to the same element.

data-loopSwiped cards go to the back of the stack instead of leaving.
data-like / data-nopeWords on the stamps. Default Like and Nope; data-like="" hides them.
data-offsetHow far each card behind peeks out, in px. Default 12.
data-autoplayA pretend finger swipes every so many ms while in view. Default 1800. A real swipe pauses it.

The stack fires swipe with detail.card and detail.direction (left or right). With focus, the arrow keys swipe too, and animacious.swipeCard(stack, 'left') swipes from code.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Swipe Cards: a stack of cards you swipe left or right. The top card follows
    // your finger and tilts, a "Like" or "Nope" stamp fades in, and past a point
    // (or with a quick flick) it flies off while the next card rises to the top.
    // Let go early and it springs back.
    // Usage:
    //   <div data-swipe-cards>
    //     <div>Card one</div>
    //     <div>Card two</div>
    //   </div>
    //   The first child is the top card. Give the container a size.
    // Optional: data-loop (swiped cards go to the back of the stack instead of
    // leaving), data-like / data-nope (stamp words, default "Like" and "Nope";
    // data-like="" hides the stamps), data-offset (px each card behind peeks
    // out, default 12), data-autoplay (a pretend finger swipes every so many
    // ms while in view, default 1800; a real swipe pauses it).
    // Events: the container fires "swipe" with detail { card, direction }
    // ("left" or "right"). Arrow keys swipe when the stack has focus.
    // Script use: animacious.swipeCard(container, 'left' | 'right').
    var DEFAULTS = { offset: 12, autoplay: 1800, like: 'Like', nope: 'Nope' };

    var css =
      '.swc{position:relative;display:grid;user-select:none;-webkit-user-select:none;outline-offset:6px}' +
      '.swc-card{grid-area:1/1;position:relative;touch-action:none;will-change:transform;cursor:grab}' +
      '.swc-card.swc-held{cursor:grabbing}' +
      '.swc-stamp{position:absolute;top:14px;padding:4px 10px;border:3px solid currentColor;border-radius:8px;' +
      'font:800 clamp(14px,4vw,20px)/1 system-ui,sans-serif;letter-spacing:.08em;text-transform:uppercase;opacity:0;pointer-events:none}' +
      '.swc-like{left:14px;color:#16a34a;transform:rotate(-14deg)}' +
      '.swc-nope{right:14px;color:#e11d48;transform:rotate(14deg)}' +
      '.swc-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 cards = Array.from(el.children);
      if (!cards.length) return;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var offset = d.offset !== undefined ? +d.offset : DEFAULTS.offset;
      var like = d.like !== undefined ? d.like : DEFAULTS.like;
      var nope = d.nope !== undefined ? d.nope : DEFAULTS.nope;
      var thumb = !!el.closest('[aria-hidden="true"]');
      el.classList.add('swc');
      var s = el._swc = { stack: cards.slice(), busy: false, userAt: -1e9 };

      cards.forEach(function (c) {
        c.classList.add('swc-card');
        if (like) {
          c.appendChild(stamp('swc-like', like));
          c.appendChild(stamp('swc-nope', nope));
        }
      });

      function stamp(cls, text) {
        var sp = document.createElement('span');
        sp.className = 'swc-stamp ' + cls;
        sp.textContent = text;
        sp.setAttribute('aria-hidden', 'true');
        return sp;
      }

      // Lay the stack out: each card behind sits a little lower and smaller.
      function layout(animate, lift) {
        var n = s.stack.length;
        s.stack.forEach(function (c, k) {
          c.style.zIndex = n - k;
          c.setAttribute('aria-hidden', k ? 'true' : 'false');
          var depth = Math.max(0, k - (lift || 0));
          var props = { y: Math.min(depth, 2) * offset, scale: 1 - Math.min(depth, 2) * 0.05, opacity: k < 3 ? 1 : 0 };
          if (k === 0 && !lift) { props.x = 0; props.rotate = 0; }
          if (animate && !reduced()) { props.duration = 420; props.ease = 'out(3)'; anime.animate(c, props); }
          else anime.utils.set(c, props);
        });
      }
      layout(false);

      var W = 1, drag = null;
      function top() { return s.stack[0]; }

      function update(x, y) {
        var c = top();
        var p = Math.max(-1, Math.min(1, x / (W * 0.4)));
        anime.utils.set(c, { x: x, y: y, rotate: x / W * 18 });
        if (like) {
          c.querySelector('.swc-like').style.opacity = Math.max(0, p);
          c.querySelector('.swc-nope').style.opacity = Math.max(0, -p);
        }
        // The next card rises as the top one leaves.
        var a = Math.abs(p);
        s.stack.slice(1, 4).forEach(function (n, i) {
          var depth = Math.min(i + 1 - a, 2);
          anime.utils.set(n, { y: depth * offset, scale: 1 - depth * 0.05 });
        });
      }

      function start(px, py) {
        if (s.busy || !s.stack.length) return false;
        W = el.clientWidth || 1;
        drag = { sx: px, sy: py, x: 0, y: 0, samples: [{ t: performance.now(), x: px }] };
        top().classList.add('swc-held');
        return true;
      }

      function moveTo(px, py) {
        if (!drag) return;
        drag.x = px - drag.sx;
        drag.y = (py - drag.sy) * 0.5;
        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();
        update(drag.x, drag.y);
      }

      function end() {
        if (!drag) return;
        var a = drag.samples[0], b = drag.samples[drag.samples.length - 1];
        var v = b.t > a.t && performance.now() - b.t < 80 ? (b.x - a.x) / ((b.t - a.t) / 1000) : 0;
        var x = drag.x, y = drag.y;
        top().classList.remove('swc-held');
        drag = null;
        if (Math.abs(x) > W * 0.35 || (Math.abs(v) > 650 && Math.abs(x) > 20 && v * x > 0)) fly(x > 0 ? 'right' : 'left', x, y);
        else springBack();
      }

      function springBack() {
        var c = top();
        var r = reduced();
        anime.animate(c, { x: 0, y: 0, rotate: 0, duration: r ? 0 : 650, ease: 'outElastic(1, .75)' });
        if (like) anime.animate(c.querySelectorAll('.swc-stamp'), { opacity: 0, duration: r ? 0 : 200 });
        layout(true, 0);
      }

      // Throw the top card off the side, then bring the next one up.
      function fly(dir, x, y) {
        if (s.busy || !s.stack.length) return;
        s.busy = true;
        W = el.clientWidth || 1;
        var c = s.stack.shift();
        var sign = dir === 'right' ? 1 : -1;
        if (like) c.querySelector(dir === 'right' ? '.swc-like' : '.swc-nope').style.opacity = 1;
        layout(true);
        var r = reduced();
        anime.animate(c, {
          x: sign * W * 1.6,
          y: (y || 0) + 30,
          rotate: sign * 32,
          opacity: { to: 0, duration: r ? 0 : 420, ease: 'in(3)' },
          duration: r ? 0 : 420,
          ease: 'out(2)',
          onComplete: function () {
            if (loop) {
              s.stack.push(c);
              if (like) anime.utils.set(c.querySelectorAll('.swc-stamp'), { opacity: 0 });
              anime.utils.set(c, { x: 0, y: 2 * offset, rotate: 0, scale: 0.9, opacity: 0 });
              layout(true);
            } else {
              c.style.visibility = 'hidden';
            }
            s.busy = false;
          },
        });
        el.dispatchEvent(new CustomEvent('swipe', { detail: { card: c, direction: dir } }));
      }
      s.fly = fly;

      if (!thumb) {
        if (!el.hasAttribute('tabindex')) el.tabIndex = 0;
        if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Card stack: use the left and right arrow keys to swipe');
        el.addEventListener('pointerdown', function (e) {
          var c = top();
          if (e.button || !c || !c.contains(e.target)) return;
          if (!start(e.clientX, e.clientY)) return;
          s.userAt = performance.now();
          e.preventDefault();
          el.setPointerCapture(e.pointerId);
        });
        el.addEventListener('pointermove', function (e) {
          if (!drag) return;
          s.userAt = performance.now();
          moveTo(e.clientX, e.clientY);
        });
        el.addEventListener('pointerup', end);
        el.addEventListener('pointercancel', end);
        el.addEventListener('keydown', function (e) {
          if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
          e.preventDefault();
          s.userAt = performance.now();
          fly(e.key === 'ArrowRight' ? 'right' : 'left', 0, 0);
        });
      }

      if (d.autoplay === undefined || d.autoplay === 'false' || reduced()) return;
      var every = +d.autoplay || DEFAULTS.autoplay;
      var finger = document.createElement('span');
      finger.className = 'swc-finger';
      finger.setAttribute('aria-hidden', 'true');
      el.appendChild(finger);
      var visible = false, timer = 0, step = 0;
      // Right, left, a hesitant drag that springs back, left again.
      var PATTERN = [0.5, -0.5, 0.2, -0.55];
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        clearTimeout(timer);
        if (visible) timer = setTimeout(play, 500);
      }).observe(el);

      function play() {
        if (!visible) return;
        if (performance.now() - s.userAt < 5000 || s.busy || drag || !s.stack.length) {
          timer = setTimeout(play, 1000);
          return;
        }
        var w = el.clientWidth, h = el.clientHeight;
        var amt = PATTERN[step++ % PATTERN.length];
        var sx = w * 0.5, sy = h * 0.6;
        var p = { x: sx, y: sy };
        anime.utils.set(finger, { x: sx, y: sy, scale: 1.3 });
        anime.animate(finger, { opacity: 1, scale: 1, duration: 180, ease: 'out(2)' });
        setTimeout(function () {
          if (!start(sx, sy)) { timer = setTimeout(play, 600); return; }
          anime.animate(p, {
            x: sx + amt * w,
            y: sy - 14,
            duration: 560,
            ease: 'inOut(2)',
            onUpdate: function () {
              moveTo(p.x, p.y);
              anime.utils.set(finger, { x: p.x, y: p.y });
            },
            onComplete: function () {
              end();
              anime.animate(finger, { opacity: 0, scale: 0.7, duration: 240, ease: 'out(2)' });
              timer = setTimeout(play, every);
            },
          });
        }, 200);
      }
    }

    function init() {
      document.querySelectorAll('[data-swipe-cards]').forEach(function (el) {
        if (el._swc) return;
        setup(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.swipeCard = function (el, dir) { if (el._swc) el._swc.fly(dir === 'left' ? 'left' : 'right', 0, 0); };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-swipe-cards data-loop style="width: 260px; height: 340px">
  <div class="card">Lisbon</div>
  <div class="card">Kyoto</div>
  <div class="card">Oaxaca</div>
</div>

<script>
  document.querySelector('[data-swipe-cards]').addEventListener('swipe', (e) => {
    console.log(e.detail.direction);
  });
</script>