← Gestures

Kanban Drag

Pick up a card and it lifts and tilts in your hand, swaying as you carry it. A dashed gap follows it from column to column while the other cards slide aside, and when you let go it drops into the gap and straightens up.

Logo
Pricing page
Copy
Nav menu
Footer
Wireframes

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-kanban-drag to the board.
  3. Inside it, give each column data-column="Title". Its children are the cards. The script adds the title and a count.
  4. Listen for cardmove to save where each card went.

Options

Add any of these attributes to the same element.

data-tiltHow far a carried card leans, in degrees. Default 4.
data-autoplayA pretend hand moves a card every so many ms while in view. Default 2200. A real drag pauses it.

On touch screens, press and hold a card for a moment to pick it up, so a quick swipe still scrolls the page. The board fires cardmove with detail.card, detail.from, detail.to (the columns) and detail.index.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Kanban Drag: pick up a card and it lifts and tilts in your hand, swaying
    // as you carry it. A dashed gap follows it between columns and the other
    // cards slide aside; let go and it drops into the gap and straightens up.
    // Usage:
    //   <div data-kanban-drag>
    //     <div data-column="To do"><div>Card</div><div>Card</div></div>
    //     <div data-column="Doing"><div>Card</div></div>
    //     <div data-column="Done"></div>
    //   </div>
    //   Each child with data-column is a column (the attribute is its title,
    //   shown with a count); its children are the cards.
    // Optional: data-tilt (degrees a carried card leans, default 4),
    // data-autoplay (a pretend hand moves a card every so many ms while in
    // view, default 2200; a real drag pauses it).
    // On touch screens, press and hold a card for a moment to pick it up.
    // Events: the board fires "cardmove" with detail { card, from, to, index }
    // (from and to are the column elements).
    var DEFAULTS = { tilt: 4, autoplay: 2200 };

    var css =
      '.kbd{user-select:none;-webkit-user-select:none}' +
      '.kbd-col{display:flex;flex-direction:column;min-width:0}' +
      '.kbd-head{display:flex;align-items:center;justify-content:space-between;gap:6px}' +
      '.kbd-list{display:flex;flex-direction:column;flex:1;min-height:40px}' +
      '.kbd-card{cursor:grab;touch-action:manipulation;-webkit-touch-callout:none}' +
      '.kbd-gap{background:transparent !important;box-shadow:none !important;outline:2px dashed rgba(106,13,173,.35);outline-offset:-2px;color:transparent !important}' +
      '.kbd-gap>*{visibility:hidden}' +
      '.kbd-ghost{position:absolute !important;left:0;top:0;margin:0 !important;z-index:30;pointer-events:none;cursor:grabbing;' +
      'box-shadow:0 18px 36px rgba(30,14,60,.22) !important}' +
      '.kbd-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:40}';
    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 tilt = d.tilt !== undefined ? +d.tilt : DEFAULTS.tilt;
      var thumb = !!el.closest('[aria-hidden="true"]');
      el.classList.add('kbd');
      if (getComputedStyle(el).position === 'static') el.style.position = 'relative';

      var cols = Array.from(el.querySelectorAll(':scope > [data-column]')).map(function (c) {
        c.classList.add('kbd-col');
        var list = document.createElement('div');
        list.className = 'kbd-list';
        Array.from(c.children).forEach(function (card) {
          card.classList.add('kbd-card');
          list.appendChild(card);
        });
        var head = document.createElement('div');
        head.className = 'kbd-head';
        var title = document.createElement('span');
        title.textContent = c.dataset.column;
        var count = document.createElement('b');
        count.className = 'kbd-count';
        head.appendChild(title);
        head.appendChild(count);
        c.appendChild(head);
        c.appendChild(list);
        return { el: c, list: list, count: count };
      });
      if (!cols.length) return;

      function counts() {
        cols.forEach(function (c) { c.count.textContent = c.list.children.length; });
      }
      counts();

      function rel(r) {
        var b = el.getBoundingClientRect();
        return { x: r.left - b.left, y: r.top - b.top, w: r.width, h: r.height };
      }

      // Move the gap, sliding every card that shifts from where it was (FLIP).
      function relocate(card, list, before) {
        if (card.parentNode === list && card.nextSibling === before) return;
        var all = Array.from(el.querySelectorAll('.kbd-card:not(.kbd-ghost)'));
        var old = all.map(function (n) { return n.getBoundingClientRect(); });
        list.insertBefore(card, before);
        all.forEach(function (n, i) {
          var r = n.getBoundingClientRect();
          var dx = old[i].left - r.left, dy = old[i].top - r.top;
          if ((dx || dy) && !reduced()) anime.animate(n, { x: [dx, 0], y: [dy, 0], duration: 260, ease: 'out(3)' });
        });
        counts();
      }

      var drag = null, userAt = -1e9;

      function start(card, px, py) {
        var r = rel(card.getBoundingClientRect());
        var ghost = card.cloneNode(true);
        ghost.classList.add('kbd-ghost');
        ghost.setAttribute('aria-hidden', 'true');
        ghost.style.width = r.w + 'px';
        ghost.style.height = r.h + 'px';
        el.appendChild(ghost);
        card.classList.add('kbd-gap');
        var from = card.parentNode;
        drag = { card: card, ghost: ghost, from: from, ox: px - r.x, oy: py - r.y, x: r.x, y: r.y, w: r.w, h: r.h,
          rot: 0, vx: 0, lastX: px, lastT: performance.now(), raf: 0 };
        anime.utils.set(ghost, { x: r.x, y: r.y });
        anime.animate(ghost, { scale: 1.06, duration: reduced() ? 0 : 200, ease: 'out(2)' });
        sway();
      }

      // The card leans in the direction of travel, settling back to a gentle tilt.
      function sway() {
        var g = drag;
        if (!g) return;
        g.vx *= 0.88;
        var target = tilt + Math.max(-12, Math.min(12, g.vx * 0.9));
        g.rot += (target - g.rot) * 0.2;
        anime.utils.set(g.ghost, { x: g.x, y: g.y, rotate: g.rot });
        g.raf = requestAnimationFrame(sway);
      }

      function moveTo(px, py) {
        var g = drag;
        if (!g) return;
        var now = performance.now();
        var dt = Math.max(8, now - g.lastT);
        g.vx = g.vx * 0.5 + ((px - g.lastX) / dt * 16) * 0.5;
        g.lastX = px; g.lastT = now;
        g.x = px - g.ox;
        g.y = py - g.oy;
        // Which column is the card's centre over, and where among its cards?
        var cx = g.x + g.w / 2, cy = g.y + g.h / 2;
        var best = null, bestD = Infinity;
        cols.forEach(function (c) {
          var r = rel(c.el.getBoundingClientRect());
          var dd = cx < r.x ? r.x - cx : cx > r.x + r.w ? cx - r.x - r.w : 0;
          if (dd < bestD) { bestD = dd; best = c; }
        });
        var before = null;
        Array.from(best.list.children).some(function (n) {
          if (n === g.card) return false;
          var r = rel(n.getBoundingClientRect());
          if (cy < r.y + r.h / 2) { before = n; return true; }
          return false;
        });
        relocate(g.card, best.list, before);
      }

      function end() {
        var g = drag;
        if (!g) return;
        drag = null;
        cancelAnimationFrame(g.raf);
        var r = rel(g.card.getBoundingClientRect());
        anime.animate(g.ghost, {
          x: r.x, y: r.y, rotate: 0, scale: 1,
          duration: reduced() ? 0 : 320,
          ease: 'out(3)',
          onComplete: function () {
            g.ghost.remove();
            g.card.classList.remove('kbd-gap');
            var to = g.card.parentNode;
            el.dispatchEvent(new CustomEvent('cardmove', { detail: {
              card: g.card,
              from: g.from.parentNode,
              to: to.parentNode,
              index: Array.prototype.indexOf.call(to.children, g.card),
            } }));
          },
        });
      }

      if (!thumb) {
        var press = null;
        el.addEventListener('pointerdown', function (e) {
          var card = e.target.closest('.kbd-card');
          if (e.button || drag || !card || !el.contains(card)) return;
          var b = el.getBoundingClientRect();
          press = { card: card, id: e.pointerId, x: e.clientX, y: e.clientY, bx: e.clientX - b.left, by: e.clientY - b.top, touch: e.pointerType === 'touch', timer: 0 };
          userAt = performance.now();
          if (press.touch) {
            // Hold to pick up, so a quick swipe still scrolls the page.
            press.timer = setTimeout(function () {
              if (!press) return;
              card.setPointerCapture(press.id);
              start(card, press.bx, press.by);
              if (navigator.vibrate) navigator.vibrate(8);
            }, 260);
          }
        });
        el.addEventListener('pointermove', function (e) {
          if (!press || e.pointerId !== press.id) return;
          userAt = performance.now();
          var b = el.getBoundingClientRect();
          var px = e.clientX - b.left, py = e.clientY - b.top;
          if (!drag) {
            if (Math.abs(e.clientX - press.x) + Math.abs(e.clientY - press.y) < 6) return;
            if (press.touch) { clearTimeout(press.timer); press = null; return; }
            press.card.setPointerCapture(e.pointerId);
            start(press.card, press.bx, press.by);
          }
          moveTo(px, py);
        });
        function up() {
          if (press) clearTimeout(press.timer);
          press = null;
          end();
        }
        el.addEventListener('pointerup', up);
        el.addEventListener('pointercancel', up);
        // Once a card is picked up, a moving finger carries it instead of scrolling.
        el.addEventListener('touchmove', function (e) { if (drag && e.cancelable) e.preventDefault(); }, { passive: false });
        el.addEventListener('contextmenu', function (e) { if (press || drag) e.preventDefault(); });
      }

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

      // Take a card from the fullest column and carry it to the next one.
      function play() {
        if (!visible) return;
        if (performance.now() - userAt < 5000 || drag || cols.length < 2) { timer = setTimeout(play, 1000); return; }
        var src = cols[0], most = -1;
        cols.forEach(function (c, i) {
          var k = cols[(i + turn) % cols.length];
          if (k.list.children.length > most) { most = k.list.children.length; src = k; }
        });
        turn++;
        if (!most) { timer = setTimeout(play, 1000); return; }
        var dst = cols[(cols.indexOf(src) + 1) % cols.length];
        var cards = src.list.children;
        var card = cards[Math.floor(Math.random() * cards.length)];
        var r = rel(card.getBoundingClientRect());
        var sx = r.x + r.w * 0.55, sy = r.y + r.h * 0.5;
        var dl = rel(dst.list.getBoundingClientRect());
        var dc = dst.list.children;
        var at = Math.floor(Math.random() * (dc.length + 1));
        var ty = at < dc.length ? rel(dc[at].getBoundingClientRect()).y + 4 : (dc.length ? rel(dc[dc.length - 1].getBoundingClientRect()).y + r.h * 1.1 : dl.y + r.h / 2);
        var tx = sx - r.x + dl.x;
        var p = { x: sx, y: sy, t: 0 };
        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 (drag) { timer = setTimeout(play, 1000); return; }
          start(card, sx, sy);
          anime.animate(p, {
            t: 1,
            duration: 900,
            delay: 160,
            ease: 'inOut(2)',
            onUpdate: function () {
              // A gentle arc: up a little, across, then down into place.
              var x = sx + (tx - sx) * p.t;
              var y = sy + (ty - sy) * p.t - Math.sin(p.t * Math.PI) * 18;
              moveTo(x, y);
              anime.utils.set(finger, { x: x, y: y });
            },
            onComplete: function () {
              setTimeout(function () {
                end();
                anime.animate(finger, { opacity: 0, scale: 0.7, duration: 240, ease: 'out(2)' });
                timer = setTimeout(play, every);
              }, 140);
            },
          });
        }, 200);
      }
    }

    function init() {
      document.querySelectorAll('[data-kanban-drag]').forEach(function (el) {
        if (el._kbd) return;
        el._kbd = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-kanban-drag style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px">
  <div data-column="To do">
    <div class="card">Logo</div>
    <div class="card">Pricing page</div>
  </div>
  <div data-column="Doing">
    <div class="card">Nav menu</div>
  </div>
  <div data-column="Done"></div>
</div>