← Gestures

Draggable with Inertia

Grab a piece and throw it. It keeps going after you let go, slows down with friction and bounces off the edges of its box, tilting slightly in the direction it travels.

Throw me

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-draggable-with-inertia to a container with a height. It is the box things move in.
  3. Put the pieces you want to throw inside it as its children.

Options

Add any of these attributes to the same element.

data-frictionShare of speed kept each frame. Closer to 1 glides further. Default 0.955.
data-bounceShare of speed kept when a piece hits an edge. 0 stops dead. Default 0.7.
data-autoplayA pretend finger throws the pieces while the box is in view, every so many ms. Default 2600. A real drag pauses it.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Draggable with Inertia: grab an element and throw it. It keeps going after
    // you let go, slows down with friction and bounces off the edges of its box,
    // tilting a little in the direction it travels.
    // Usage: <div data-draggable-with-inertia><div>Throw me</div></div>
    //   The container is the box things move in (give it a height); each child
    //   can be thrown.
    // Optional: data-friction (share of speed kept each frame, default 0.955;
    // closer to 1 glides further), data-bounce (share of speed kept on hitting
    // an edge, default 0.7), data-autoplay (a pretend finger throws the pieces
    // every so many ms while in view, default 2600; a real drag pauses it).
    var DEFAULTS = { friction: 0.955, bounce: 0.7, autoplay: 2600 };

    var css =
      '.dwi{user-select:none;-webkit-user-select:none}' +
      '.dwi-item{position:absolute;left:0;top:0;margin:0;cursor:grab;touch-action:none;will-change:transform}' +
      '.dwi-item.dwi-held{cursor:grabbing;z-index:2}' +
      '.dwi-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: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 friction = Math.min(0.999, +d.friction || DEFAULTS.friction);
      var bounce = d.bounce !== undefined ? +d.bounce : DEFAULTS.bounce;
      var thumb = !!el.closest('[aria-hidden="true"]');
      el.classList.add('dwi');
      if (getComputedStyle(el).position === 'static') el.style.position = 'relative';

      var items = Array.from(el.children).map(function (node, i) {
        node.classList.add('dwi-item');
        return { node: node, x: 0, y: 0, vx: 0, vy: 0, rot: 0, scale: 1, held: false, samples: [], i: i };
      });
      if (!items.length) return;
      var W = 0, H = 0, raf = 0, last = 0, placed = false;

      function measure() {
        W = el.clientWidth;
        H = el.clientHeight;
        items.forEach(function (it) {
          it.w = it.node.offsetWidth;
          it.h = it.node.offsetHeight;
        });
      }

      // Spread the pieces across the middle the first time; afterwards just keep them inside.
      function place() {
        measure();
        items.forEach(function (it, i) {
          if (!placed) {
            it.x = W * (i + 1) / (items.length + 1) - it.w / 2;
            it.y = (H - it.h) / 2;
          }
          it.x = Math.max(0, Math.min(W - it.w, it.x));
          it.y = Math.max(0, Math.min(H - it.h, it.y));
          render(it);
        });
        placed = true;
      }

      function render(it) {
        it.node.style.transform = 'translate(' + it.x.toFixed(1) + 'px,' + it.y.toFixed(1) + 'px) rotate(' +
          it.rot.toFixed(2) + 'deg) scale(' + it.scale.toFixed(3) + ')';
      }

      function local(e) {
        var r = el.getBoundingClientRect();
        return { x: e.clientX - r.left, y: e.clientY - r.top };
      }

      function grab(it, px, py) {
        measure();
        it.held = true;
        it.vx = it.vy = 0;
        it.ox = px - it.x;
        it.oy = py - it.y;
        it.samples = [{ t: performance.now(), x: px, y: py }];
        it.node.classList.add('dwi-held');
        kick();
      }

      function move(it, px, py) {
        if (!it.held) return;
        it.x = Math.max(0, Math.min(W - it.w, px - it.ox));
        it.y = Math.max(0, Math.min(H - it.h, py - it.oy));
        var now = performance.now();
        it.samples.push({ t: now, x: px, y: py });
        while (it.samples.length > 2 && now - it.samples[0].t > 90) it.samples.shift();
      }

      // Let go: the speed of the last few moves becomes the throw.
      function release(it, vx, vy) {
        if (!it.held) return;
        it.held = false;
        it.node.classList.remove('dwi-held');
        if (vx === undefined) {
          var a = it.samples[0], b = it.samples[it.samples.length - 1];
          var dt = (b.t - a.t) / 1000;
          if (performance.now() - b.t > 80 || dt <= 0) { vx = vy = 0; }
          else { vx = (b.x - a.x) / dt; vy = (b.y - a.y) / dt; }
        }
        var max = 4000;
        it.vx = Math.max(-max, Math.min(max, vx));
        it.vy = Math.max(-max, Math.min(max, vy));
        kick();
      }

      function kick() {
        if (raf) return;
        last = performance.now();
        raf = requestAnimationFrame(tick);
      }

      function tick(now) {
        var dt = Math.min(0.05, (now - last) / 1000);
        last = now;
        var busy = false;
        items.forEach(function (it) {
          if (!it.held) {
            it.x += it.vx * dt;
            it.y += it.vy * dt;
            if (it.x < 0) { it.x = 0; it.vx = -it.vx * bounce; }
            else if (it.x > W - it.w) { it.x = W - it.w; it.vx = -it.vx * bounce; }
            if (it.y < 0) { it.y = 0; it.vy = -it.vy * bounce; }
            else if (it.y > H - it.h) { it.y = H - it.h; it.vy = -it.vy * bounce; }
            var k = Math.pow(friction, dt * 60);
            it.vx *= k;
            it.vy *= k;
            if (Math.abs(it.vx) < 4 && Math.abs(it.vy) < 4) it.vx = it.vy = 0;
          } else {
            // While held, the tilt follows the hand's speed.
            var s = it.samples, a = s[0], b = s[s.length - 1];
            it.vx = b.t > a.t ? (b.x - a.x) / ((b.t - a.t) / 1000) : 0;
          }
          var targetRot = Math.max(-14, Math.min(14, it.vx * 0.012));
          var targetScale = it.held ? 1.07 : 1;
          it.rot += (targetRot - it.rot) * Math.min(1, dt * 10);
          it.scale += (targetScale - it.scale) * Math.min(1, dt * 14);
          render(it);
          if (it.held || it.vx || it.vy || Math.abs(it.rot) > 0.05 || Math.abs(it.scale - 1) > 0.002) busy = true;
        });
        raf = busy ? requestAnimationFrame(tick) : 0;
      }

      var userAt = -1e9;
      if (!thumb) {
        items.forEach(function (it) {
          it.node.addEventListener('pointerdown', function (e) {
            if (e.button) return;
            e.preventDefault();
            userAt = performance.now();
            it.node.setPointerCapture(e.pointerId);
            var p = local(e);
            grab(it, p.x, p.y);
          });
          it.node.addEventListener('pointermove', function (e) {
            if (!it.held) return;
            userAt = performance.now();
            var p = local(e);
            move(it, p.x, p.y);
          });
          function up() { userAt = performance.now(); release(it); }
          it.node.addEventListener('pointerup', up);
          it.node.addEventListener('pointercancel', up);
          it.node.addEventListener('dragstart', function (e) { e.preventDefault(); });
        });
      }

      new ResizeObserver(place).observe(el);
      place();

      // Autoplay: a pretend finger flicks one piece at a time.
      if (d.autoplay === undefined || d.autoplay === 'false' || reduced()) return;
      var every = +d.autoplay || DEFAULTS.autoplay;
      var finger = document.createElement('span');
      finger.className = 'dwi-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(flick, 600);
      }).observe(el);

      function flick() {
        if (!visible) return;
        var it = items[turn++ % items.length];
        if (performance.now() - userAt < 5000 || it.held || items.some(function (o) { return o.held; })) {
          timer = setTimeout(flick, 1200);
          return;
        }
        measure();
        var cx = it.x + it.w / 2, cy = it.y + it.h / 2;
        // Aim roughly at the far side, with some randomness so every throw differs.
        var ang = Math.atan2(H / 2 - cy, W / 2 - cx) + (Math.random() - 0.5) * 2.2;
        var speed = Math.max(W, H) * (2.2 + Math.random());
        var reach = Math.min(it.w, 60);
        var p = { x: cx, y: cy };
        anime.utils.set(finger, { x: cx, y: cy, scale: 1.3 });
        anime.animate(finger, { opacity: 1, scale: 1, duration: 180, ease: 'out(2)' });
        setTimeout(function () {
          grab(it, cx, cy);
          anime.animate(p, {
            x: cx + Math.cos(ang) * reach,
            y: cy + Math.sin(ang) * reach,
            duration: 260,
            ease: 'in(2)',
            onUpdate: function () {
              move(it, p.x, p.y);
              anime.utils.set(finger, { x: p.x, y: p.y });
            },
            onComplete: function () {
              release(it, Math.cos(ang) * speed, Math.sin(ang) * speed);
              anime.animate(finger, { opacity: 0, scale: 0.7, duration: 260, ease: 'out(2)' });
              timer = setTimeout(flick, every);
            },
          });
        }, 200);
      }
    }

    function init() {
      document.querySelectorAll('[data-draggable-with-inertia]').forEach(function (el) {
        if (el._dwi) return;
        el._dwi = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-draggable-with-inertia style="height: 300px">
  <div class="piece">Throw me</div>
</div>