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
- Paste the snippet just before
</head>. - Add
data-drag-to-dismissto the overlay that covers the page. The script adds a dimmed backdrop to it. - Make its first child the sheet or photo to drag.
- Listen for
dismissto remove the overlay once it has gone.
Options
Add any of these attributes to the same element.
data-direction | down (default) or any, to throw it away in any direction 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 | Milliseconds: it comes back after this long. Handy for demos. |
data-autoplay | A 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.
<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><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>