← Gestures

Drag to Dismiss

Drag a sheet or photo down to close it. It follows your finger, shrinking a touch and fading along with the dimmed backdrop. Let go past a point, or flick it, and it slides away; let go early and it springs back.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-drag-to-dismiss to the overlay that covers the page. The script adds a dimmed backdrop to it.
  3. Make its first child the sheet or photo to drag.
  4. Listen for dismiss to remove the overlay once it has gone.

Options

Add any of these attributes to the same element.

data-directiondown (default) or any, to throw it away in any direction like a photo viewer.
data-thresholdShare of its height to drag before letting go closes it. Default 0.3.
data-backdropBackdrop colour. Default rgba(15,10,30,.45); none for no backdrop.
data-restoreMilliseconds: it comes back after this long. Handy for demos.
data-autoplayA pretend finger drags it every so many ms while in view: a tug that springs back, then a real dismiss. Default 2000. A real drag pauses it.

A quick flick closes it even if it hasn’t gone far. Escape closes it too, and animacious.dragToDismiss.show(overlay) brings it back.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Drag to Dismiss: drag a sheet or photo down to close it. It follows your
    // finger, shrinking a touch and fading along with the dimmed backdrop as it
    // goes. Let go past a point (or flick it) and it slides away; let go early
    // and it springs back into place.
    // Usage:
    //   <div data-drag-to-dismiss>          the overlay (covers the page)
    //     <div class="sheet">…</div>        the first child is what you drag
    //   </div>
    // Optional: data-direction ("down", default, or "any" to throw it any way,
    // like a photo viewer), data-threshold (share of its height to drag before
    // letting go closes it, default 0.3), data-backdrop (backdrop colour,
    // default rgba(15,10,30,.45); "none" for no backdrop), data-restore (ms: it
    // comes back after this long; for demos), data-autoplay (a pretend finger
    // drags it every so many ms while in view, default 2000; a real drag
    // pauses it).
    // Events: the overlay fires "dismiss" once it has gone. Escape closes it too.
    // Script use: animacious.dragToDismiss.show(overlay) brings it back.
    var DEFAULTS = { threshold: 0.3, backdrop: 'rgba(15,10,30,.45)', autoplay: 2000 };

    var css =
      
      '.dtd-backdrop{position:absolute;inset:0;border-radius:inherit;pointer-events:none}' +
      '.dtd-panel{position:relative;touch-action:none;cursor:grab;will-change:transform,opacity;user-select:none;-webkit-user-select:none}' +
      '.dtd-panel.dtd-held{cursor:grabbing}' +
      '.dtd-gone{pointer-events:none}' +
      '.dtd-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,.85);box-shadow:0 2px 10px rgba(0,0,0,.2);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 panel = el.firstElementChild;
      if (!panel) return;
      var any = d.direction === 'any';
      var limit = +d.threshold || DEFAULTS.threshold;
      var thumb = !!el.closest('[aria-hidden="true"]');
      el.classList.add('dtd');
      if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
      panel.classList.add('dtd-panel');
      var back = null;
      if (d.backdrop !== 'none') {
        back = document.createElement('div');
        back.className = 'dtd-backdrop';
        back.setAttribute('aria-hidden', 'true');
        back.style.background = d.backdrop || DEFAULTS.backdrop;
        el.insertBefore(back, el.firstChild);
      }
      var s = el._dtd = { x: 0, y: 0, gone: false, userAt: -1e9, anim: null };
      var drag = null;

      // Everything follows from where the panel is.
      function draw(x, y) {
        s.x = x; s.y = y;
        var dist = any ? Math.sqrt(x * x + y * y) : Math.max(0, y);
        var p = Math.min(1, dist / (panel.offsetHeight || 1));
        anime.utils.set(panel, { x: x, y: y, scale: 1 - p * 0.1, opacity: 1 - p * 0.55 });
        if (back) back.style.opacity = 1 - p;
      }

      function tween(x, y, ms, ease, done) {
        if (s.anim) s.anim.pause();
        var o = { x: s.x, y: s.y };
        s.anim = anime.animate(o, {
          x: x, y: y,
          duration: reduced() ? 0 : ms,
          ease: ease,
          onUpdate: function () { draw(o.x, o.y); },
          onComplete: done,
        });
      }

      function grab(px, py) {
        if (s.gone) return false;
        if (s.anim) s.anim.pause();
        drag = { sx: px - s.x, sy: py - s.y, samples: [{ t: performance.now(), x: px, y: py }] };
        panel.classList.add('dtd-held');
        return true;
      }

      function moveTo(px, py) {
        if (!drag) return;
        var x = any ? px - drag.sx : 0;
        var y = py - drag.sy;
        if (!any && y < 0) y = y / (1 - y / 40) * 0.6;   // stiff the wrong way
        draw(x, y);
        var now = performance.now();
        drag.samples.push({ t: now, x: px, y: py });
        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 fresh = performance.now() - b.t < 80 && dt > 0;
        var vx = fresh ? (b.x - a.x) / dt : 0, vy = fresh ? (b.y - a.y) / dt : 0;
        drag = null;
        panel.classList.remove('dtd-held');
        var h = panel.offsetHeight;
        var dist = any ? Math.sqrt(s.x * s.x + s.y * s.y) : s.y;
        var speed = any ? Math.sqrt(vx * vx + vy * vy) : vy;
        if (dist > h * limit || (speed > 700 && dist > 16)) dismiss(vx, vy);
        else tween(0, 0, 600, 'outElastic(1, .8)');
      }

      function dismiss(vx, vy) {
        if (s.gone) return;
        s.gone = true;
        var far = el.offsetHeight + panel.offsetHeight;
        var tx = 0, ty = far;
        if (any) {
          var len = Math.sqrt(vx * vx + vy * vy);
          var ux = len > 200 ? vx / len : s.x / (Math.sqrt(s.x * s.x + s.y * s.y) || 1);
          var uy = len > 200 ? vy / len : s.y / (Math.sqrt(s.x * s.x + s.y * s.y) || 1);
          tx = s.x + ux * far; ty = s.y + uy * far;
        }
        tween(tx, ty, 360, 'in(2)', function () {
          el.classList.add('dtd-gone');
          panel.setAttribute('aria-hidden', 'true');
          el.dispatchEvent(new CustomEvent('dismiss'));
          var r = +d.restore;
          if (r) setTimeout(function () { show(); }, r);
        });
      }

      function show() {
        if (!s.gone) return;
        s.gone = false;
        el.classList.remove('dtd-gone');
        panel.removeAttribute('aria-hidden');
        draw(0, any ? 40 : panel.offsetHeight);
        if (back) back.style.opacity = 0;
        tween(0, 0, 520, 'out(3)');
      }
      s.show = show;

      if (!thumb) {
        panel.addEventListener('pointerdown', function (e) {
          if (e.button || e.target.closest('a,button,input,select,textarea')) return;
          s.userAt = performance.now();
          if (!grab(e.clientX, e.clientY)) return;
          e.preventDefault();
          panel.setPointerCapture(e.pointerId);
        });
        panel.addEventListener('pointermove', function (e) {
          if (!drag) return;
          s.userAt = performance.now();
          moveTo(e.clientX, e.clientY);
        });
        panel.addEventListener('pointerup', release);
        panel.addEventListener('pointercancel', release);
        el.addEventListener('keydown', function (e) { if (e.key === 'Escape') dismiss(0, 1); });
      }
      draw(0, 0);

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

      // A tentative drag that springs back, then a real one that closes it.
      function play() {
        if (!visible) return;
        if (performance.now() - s.userAt < 5000 || drag || s.gone) { timer = setTimeout(play, 800); return; }
        var big = step++ % 2 === 1;
        var b = el.getBoundingClientRect(), r = panel.getBoundingClientRect();
        var sx = r.left - b.left + r.width / 2, sy = r.top - b.top + Math.min(24, r.height * 0.2);
        var reach = r.height * (big ? 0.62 : 0.18);
        var p = { 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 (!grab(sx, sy)) { timer = setTimeout(play, 800); return; }
          anime.animate(p, {
            y: sy + reach,
            duration: big ? 620 : 420,
            ease: big ? 'in(1.6)' : 'inOut(2)',
            onUpdate: function () {
              moveTo(sx, p.y);
              anime.utils.set(finger, { y: p.y });
            },
            onComplete: function () {
              setTimeout(function () {
                release();
                anime.animate(finger, { opacity: 0, scale: 0.7, duration: 240, ease: 'out(2)' });
                timer = setTimeout(play, big ? every + (+d.restore || 0) + 400 : every * 0.7);
              }, big ? 0 : 120);
            },
          });
        }, 200);
      }
    }

    function init() {
      document.querySelectorAll('[data-drag-to-dismiss]').forEach(function (el) {
        if (el._dtd) return;
        setup(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.dragToDismiss = { show: function (el) { if (el._dtd) el._dtd.show(); } };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div class="overlay" data-drag-to-dismiss>
  <div class="sheet">
    <h2>Share</h2>
    …
  </div>
</div>

<script>
  document.querySelector('[data-drag-to-dismiss]').addEventListener('dismiss', (e) => {
    e.target.remove();
  });
</script>