Exit-intent Popup
Head for the tab bar as if to leave and a popup drops in from the top, swinging gently into place over a dimmed page with one last offer. It shows once per visit and goes away with a click anywhere else.
🛍️ Shop
Wait! Before you go…
Take 10% off your first order with WELCOME10.
How to use it
- Paste the snippet just before
</head>. - Add
data-exit-intent-popupto your popup. - Add
data-exit-closeto its buttons that should close it.
Options
Add any of these attributes to the same element.
data-delay | Time on the page before it can show at all. Default 3000 ms. |
data-key | The sessionStorage key that keeps it to once per visit. Default exit-intent-shown; set it empty to show it every time. |
data-contained | Watch and cover only its parent, which can show it again and again (for previews). An element inside marked data-exit-zone plays the part of the tab bar. |
data-autoplay | Contained only: a pretend pointer drifts up to the top by itself while in view, until a real one moves in. |
On phones there’s no pointer to watch, so a quick flick back up the page counts as the same intent. Focus moves into the popup and back again, and Esc, a click on the dimmed page or data-exit-close closes it. It fires an exitintent event as it shows. From code: animacious.exitIntent(popup).
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Exit-intent Popup: when the pointer heads up towards the tab bar, as if
// to leave, a popup drops in from the top over a dimmed page with a last
// offer. It shows once per visit and is easy to wave away.
// Usage:
// <div data-exit-intent-popup aria-label="Before you go">
// <h2>Wait! Before you go…</h2>
// <p>Take 10% off your first order.</p>
// <a href="/shop">Claim offer</a>
// <button data-exit-close>No thanks</button>
// </div>
// On touch screens, where there is no pointer to watch, a quick scroll back
// up the page counts as the same intent.
// Optional: data-delay (ms on the page before it can show, default 3000),
// data-key (sessionStorage key so it shows once per visit, default
// "exit-intent-shown"; data-key="" shows it every time), data-contained
// (it watches and covers only its parent, and can show again; the top
// strip of the parent, or an element marked data-exit-zone, plays the part
// of the tab bar), data-autoplay (contained only: a pretend pointer drifts
// up to the top by itself while in view, until a real pointer moves in).
// Event: "exitintent" on the popup when it shows.
// Script use: animacious.exitIntent(el) shows it, animacious.exitIntent(el, false) hides it.
var DEFAULTS = { delay: 3000, key: 'exit-intent-shown', hold: 2600 };
var css =
'.eip{position:fixed;left:50%;top:24px;z-index:9101;box-sizing:border-box;width:min(420px,calc(100% - 32px));visibility:hidden}' +
'.eip.eip-c{position:absolute;top:14%;z-index:4;width:min(380px,calc(100% - 24px))}' +
'.eip-dim{position:fixed;inset:0;z-index:9100;background:rgba(14,10,28,.45);opacity:0;visibility:hidden}' +
'.eip-dim.eip-c{position:absolute;z-index:3}' +
':where(.eip){padding:24px;border-radius:20px;background:#fff;color:#1a1625;box-shadow:0 24px 60px rgba(14,10,28,.3);font:15px/1.5 system-ui,sans-serif;text-align:center}' +
'.eip-cur{position:absolute;left:0;top:0;z-index:5;width:22px;height:22px;pointer-events:none;opacity:0;filter:drop-shadow(0 2px 3px rgba(0,0,0,.3))}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var CURSOR = '<svg viewBox="0 0 24 24" width="22" height="22"><path d="M4 2.5v17l4.6-4.4 3 6.6 3-1.4-3-6.4H18z" fill="#111" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/></svg>';
var popups = [];
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function on(v) { return v !== undefined && v !== 'false'; }
function seen(key) { try { return key && sessionStorage.getItem(key); } catch (e) { return null; } }
function mark(key) { try { if (key) sessionStorage.setItem(key, '1'); } catch (e) { /* private mode */ } }
function setup(el) {
var d = el.dataset;
var p = {
el: el, open: false, born: Date.now(),
contained: on(d.contained),
delay: d.delay !== undefined ? +d.delay : DEFAULTS.delay,
key: d.key !== undefined ? d.key : DEFAULTS.key,
decorative: !!el.closest('[aria-hidden="true"]')
};
el._eip = p;
el.classList.add('eip');
var dim = document.createElement('div');
dim.className = 'eip-dim';
dim.setAttribute('aria-hidden', 'true');
if (p.contained) { el.classList.add('eip-c'); dim.classList.add('eip-c'); p.delay = d.delay !== undefined ? +d.delay : 0; }
el.parentNode.insertBefore(dim, el);
p.dim = dim;
anime.utils.set(el, { x: '-50%' });
if (!p.decorative) {
if (!el.getAttribute('role')) el.setAttribute('role', 'dialog');
if (!p.contained) el.setAttribute('aria-modal', 'true');
if (!el.hasAttribute('tabindex')) el.tabIndex = -1;
}
dim.addEventListener('click', function () { hide(p); });
el.addEventListener('click', function (e) { if (e.target.closest('[data-exit-close]')) hide(p); });
el.addEventListener('keydown', function (e) { if (e.key === 'Escape') { e.stopPropagation(); hide(p); } });
popups.push(p);
return p;
}
function ready(p) {
return !p.open && Date.now() - p.born >= p.delay && (p.contained || !seen(p.key));
}
function show(p, byUser) {
var el = p.el;
if (p.open) return;
p.open = true;
if (!p.contained) mark(p.key);
el.style.visibility = p.dim.style.visibility = 'visible';
el.dispatchEvent(new CustomEvent('exitintent', { bubbles: true }));
var kids = Array.from(el.children);
anime.utils.remove([el, p.dim].concat(kids));
if (!p.decorative && (byUser || !p.contained)) {
p.back = document.activeElement;
setTimeout(function () { if (p.open) el.focus({ preventScroll: true }); }, 50);
}
if (reduced()) {
anime.utils.set(el, { y: 0, opacity: 1, rotate: 0 });
anime.utils.set(p.dim, { opacity: 1 });
anime.utils.set(kids, { opacity: 1, y: 0 });
return;
}
anime.animate(p.dim, { opacity: [0, 1], duration: 300, ease: 'out(2)' });
anime.animate(el, {
y: [-(el.offsetHeight + el.offsetTop + 30), 0], rotate: [-4, 0],
opacity: { to: [0, 1], duration: 160, ease: 'linear' },
ease: anime.spring({ bounce: 0.35, duration: 700 })
});
anime.animate(kids, { opacity: [0, 1], y: [-10, 0], duration: 460, delay: anime.stagger(60, { start: 220 }), ease: 'out(3)' });
}
function hide(p) {
var el = p.el;
if (!p.open) return;
p.open = false;
if (el.contains(document.activeElement)) {
if (p.back && p.back.focus && p.back !== document.body) p.back.focus({ preventScroll: true });
else document.activeElement.blur();
}
anime.utils.remove([el, p.dim]);
var done = function () { if (!p.open) el.style.visibility = p.dim.style.visibility = 'hidden'; };
if (reduced()) { anime.utils.set([el, p.dim], { opacity: 0 }); return done(); }
anime.animate(el, { y: -(el.offsetHeight + el.offsetTop + 30), rotate: 3, duration: 380, ease: 'in(3)', onComplete: done });
anime.animate(p.dim, { opacity: 0, duration: 380, ease: 'out(2)' });
}
// Whole page: the pointer leaves through the top, or rushes up to it.
function watchPage() {
var lastY = null;
document.addEventListener('mouseout', function (e) {
if (e.relatedTarget || e.clientY > 12) return;
popups.forEach(function (p) { if (!p.contained && ready(p)) show(p); });
});
document.addEventListener('pointermove', function (e) {
if (e.pointerType !== 'mouse') return;
var up = lastY !== null && e.clientY < lastY - 2;
lastY = e.clientY;
if (up && e.clientY < 10) popups.forEach(function (p) { if (!p.contained && ready(p)) show(p); });
}, { passive: true });
// Touch: a quick flick back up the page, once they've scrolled a way down.
var lastS = window.scrollY, lastT = Date.now();
window.addEventListener('scroll', function () {
var now = Date.now(), y = window.scrollY, dt = now - lastT;
if (dt > 30) {
var v = (lastS - y) / dt; // px per ms, upwards
if (v > 2.2 && lastS > 600 && !window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
popups.forEach(function (p) { if (!p.contained && ready(p)) show(p); });
}
lastS = y; lastT = now;
}
}, { passive: true });
}
// Contained: the same, inside the parent, with data-exit-zone as the tab bar.
function watchBox(p) {
var box = p.el.parentNode, lastY = null;
var zone = box.querySelector('[data-exit-zone]');
function inZone(y) {
var r = box.getBoundingClientRect();
var bottom = zone ? zone.getBoundingClientRect().bottom : r.top + r.height * 0.16;
return y < bottom;
}
box.addEventListener('pointermove', function (e) {
if (!e.isTrusted || e.pointerType !== 'mouse') return;
if (p.stopAuto) p.stopAuto();
var up = lastY !== null && e.clientY < lastY - 1;
lastY = e.clientY;
if (up && inZone(e.clientY) && ready(p)) show(p, true);
});
box.addEventListener('pointerleave', function (e) {
if (e.isTrusted && e.pointerType === 'mouse' && e.clientY <= box.getBoundingClientRect().top + 2 && ready(p)) show(p, true);
lastY = null;
});
box.addEventListener('pointerdown', function (e) {
if (!e.isTrusted) return;
if (p.stopAuto) p.stopAuto();
// On touch, a tap on the pretend tab bar stands in for heading there.
if (e.pointerType !== 'mouse' && inZone(e.clientY) && ready(p)) show(p, true);
});
}
// Demo mode: a pretend pointer wanders up to the top, then closes the popup.
function autoplay(p, hold) {
var box = p.el.parentNode, timers = [], visible = false, live = true;
var cur = document.createElement('span');
cur.className = 'eip-cur';
cur.setAttribute('aria-hidden', 'true');
cur.innerHTML = CURSOR;
box.appendChild(cur);
var close = box.querySelector('[data-exit-close]');
function later(fn, ms) { timers.push(setTimeout(fn, ms)); }
function stop() { timers.forEach(clearTimeout); timers = []; anime.utils.remove(cur); }
function run() {
stop();
if (!visible || !live) return;
var w = box.clientWidth, h = box.clientHeight;
anime.utils.set(cur, { x: w * 0.42, y: h * 0.82, opacity: 0 });
anime.animate(cur, { opacity: 1, duration: 300 });
// Wander a little, then dart up past the top.
anime.animate(cur, {
x: [w * 0.42, w * 0.56, w * 0.62], y: [h * 0.82, h * 0.6, -6],
duration: 1300, delay: 300, ease: 'inOut(2)'
});
later(function () { if (live) show(p); }, 1450);
later(function () {
if (!live || !close) return;
// Come back down onto "No thanks" and press it.
var br = box.getBoundingClientRect(), cr = close.getBoundingClientRect();
anime.animate(cur, { x: cr.left - br.left + cr.width / 2 - 4, y: cr.top - br.top + cr.height / 2 - 2, duration: 700, ease: 'inOut(3)' });
later(function () {
if (!live) return;
anime.animate(close, { scale: [1, 0.92, 1], duration: 240, ease: 'out(2)' });
hide(p);
anime.animate(cur, { opacity: 0, duration: 300, delay: 300 });
later(run, 1500);
}, 800);
}, 1450 + hold);
}
p.stopAuto = function () { if (!live) return; live = false; stop(); anime.utils.set(cur, { opacity: 0 }); };
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible && !p.open) run();
else if (!visible) stop();
}, { threshold: 0.4 }).observe(box);
}
function init() {
document.querySelectorAll('[data-exit-intent-popup]').forEach(function (el) {
if (el._eip) return;
var p = setup(el);
if (p.contained) {
watchBox(p);
if (on(el.dataset.autoplay) && !reduced()) autoplay(p, +el.dataset.autoplay || DEFAULTS.hold);
}
});
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') popups.forEach(function (p) { if (!p.contained) hide(p); });
});
watchPage();
window.animacious = window.animacious || {};
window.animacious.exitIntent = function (el, open) {
var p = el._eip || setup(el);
if (open === false) hide(p); else show(p, true);
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-exit-intent-popup aria-label="Before you go">
<h2>Wait! Before you go…</h2>
<p>Take 10% off your first order.</p>
<a href="/shop?code=WELCOME10">Claim offer</a>
<button data-exit-close>No thanks</button>
</div>