← Overlays

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

  1. Paste the snippet just before </head>.
  2. Add data-exit-intent-popup to your popup.
  3. Add data-exit-close to its buttons that should close it.

Options

Add any of these attributes to the same element.

data-delayTime on the page before it can show at all. Default 3000 ms.
data-keyThe sessionStorage key that keeps it to once per visit. Default exit-intent-shown; set it empty to show it every time.
data-containedWatch 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-autoplayContained 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).

1 · Snippet
<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>
2 · Your HTML
<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>