Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save yellow1912/0a1f4d31aec3889b5464aa293d6cfd4e to your computer and use it in GitHub Desktop.

Select an option

Save yellow1912/0a1f4d31aec3889b5464aa293d6cfd4e to your computer and use it in GitHub Desktop.
ExitIntent detection
/**
* Naive ExitIntent detector
*
* @author Vu Nguyen
* @link https://nilead.com
* @license Do Whatever The Fuck You Want
*/
(function () {
"use strict";
/** Detect free variable `global` from Node.js. */
var freeGlobal = typeof global == 'object' && global && global.Object === Object && global;
/** Detect free variable `self`. */
var freeSelf = typeof self == 'object' && self && self.Object === Object && self;
/** Used as a reference to the global object. */
var root = freeGlobal || freeSelf || Function('return this')();
var _html = document.documentElement, currentLocation = normalizeUrl(location.href);
function extend(out) {
out = out || {};
for (var i = 1; i < arguments.length; i ++) {
if (! arguments[i]) {
continue;
}
for (var key in arguments[i]) {
if (arguments[i].hasOwnProperty(key)) {
out[key] = arguments[i][key];
}
}
}
return out;
}
function normalizeUrl(url) {
if (url.indexOf('//') === 0) {
url = location.protocol + url;
}
return url.toLowerCase().replace(/([a-z])?:\/\//, '$1').split('/')[0];
}
function isExternal(url) {
return ( ( url.indexOf(':') > - 1 || url.indexOf('//') > - 1 ) && currentLocation !== normalizeUrl(url) );
}
// Helper functions
var supportsPassiveOption = false;
try {
var opts = Object.defineProperty({}, 'passive', {
get: function () {
supportsPassiveOption = true;
}.bind(this)
});
window.addEventListener('test', null, opts);
window.removeEventListener('test', null, opts);
} catch (e) {
}
function addEventListener(elem, event, fn, options, useCapture) {
if (! supportsPassiveOption && 'object' == typeof options) {
options = options.hasOwnProperty('capture') ? options.capture : false;
}
if ('undefined' === typeof options) {
options = {};
}
if (elem.addEventListener) {
elem.addEventListener(event, fn, options, useCapture || false);
} else {
elem.attachEvent("on" + event, fn); // older versions of IE
}
}
function removeEventListener(elem, event, fn, options, useCapture) {
if (! supportsPassiveOption && 'object' == typeof options) {
options = options.hasOwnProperty('capture') ? options.capture : false;
}
if ('undefined' === typeof options) {
options = false;
}
if (elem.removeEventListener) {
elem.removeEventListener(event, fn, options, useCapture || false);
} else {
elem.detachEvent("on" + event, fn); // older versions of IE
}
}
function createCookie(name, value, days) {
var expires = "";
if (days) {
var date = new Date();
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
expires = "; expires=" + date.toUTCString();
}
document.cookie = name + "=" + value + expires + "; path=/";
}
function readCookie(name) {
var nameEQ = name + "=";
var ca = document.cookie.split(';');
for (var i = 0; i < ca.length; i ++) {
var c = ca[i];
while (c.charAt(0) == ' ') c = c.substring(1, c.length);
if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length);
}
return null;
}
function eraseCookie(name) {
createCookie(name, "", - 1);
}
/**
* A pretty naive class to detects and dispatch exit intent
*
* Method 1: if the user mouse out of the current windows within the set period of time, we assume he/she wants to leave
*
* Method 2: if the user click on a link different from the current domain, we assume he/she wants to leave
*
* Method 3: if the browser triggers onbeforeunload event
*
* Unlike other plugins, this one will not care about the number of time it should trigger, in fact we believe that it should trigger as many time as necessary
* It's up to the users of the plugin to catch the exit intent and do what you want to do with it
*/
class ExitIntent {
constructor(options) {
options = options || {};
this._delayTimer = null;
this._unloadTimer = null;
this._unloadable = false;
this._active = true;
this.options = extend({
initDelay: 2000, // we can delay a bit before initializing, this allows us to trigger to early for users who don't want to see it
ignoreMultipleInstances: true, // ignore exit intent if the user is opening on multiple tabs and or windows
detectMouse: true, // should we use mouse in/out detection?
detectLink: true, // should we use link click detection?
detectUnload: true, // should we use unload detection? (risky, disabled by default)
// options for mouse detection
exitDelay: 2000, // delay (in millisecond) before we fire the event. Long delay will allow us to ignore accidental mouse movement but will increase risk of missing real events
sensitivityX: 50, // distance away from the right edge to count as exit
sensitivityY: 50, // distance away from the top edge to count as exit
// options for link detection
excludeSelector: null, // exclude certain links for detection, use querySelector :not
ignoreOpenInNewTab: true, // if the link is opened in new tab we don't count it as exit intend
// options for unload detection
unloadMessage: 'Hey, before you leave!', // the no please do not leave message
unloadSensitivity: 500, // since we capture key press to guess user exit intention, we use this ms time to set how long we wait after each key press to register the unload as exit intention
exit: function () {
}
}, options);
// to avoid spamming people, we may want add a small delay before we initialize our detector
setTimeout(() => {
this.init();
}, this.options.initDelay);
if (this.options.ignoreMultipleInstances) {
// we do not want to trigger the exit intention if multiple step is open
let instances = parseInt(readCookie('nl-exit-instances') || 0);
createCookie('nl-exit-instances', instances + 1);
addEventListener(window, 'unload', () => {
let instances = readCookie('nl-exit-instances') || 1;
createCookie('nl-exit-instances', instances - 1);
});
}
}
init() {
// do we detect using mouse movements?
if (this.options.detectMouse) {
addEventListener(_html, 'mouseenter', this.handleMouseEnter.bind(this), {
capture: false, passive: true
});
addEventListener(_html, 'mouseleave', this.handleMouseLeave.bind(this), {
capture: false, passive: true
});
}
// do we detect using the link interactions?
if (this.options.detectLink) {
let links;
if (this.options.excludeSelector) {
links = _html.querySelectorAll('a:not(' + this.options.excludeSelector + ')');
} else {
links = _html.getElementsByTagName('a');
}
for (let link of links) {
let href = link.getAttribute('href');
if (this.options.ignoreOpenInNewTab && '_blank' == link.getAttribute('target')) {
continue;
}
// we only care about http, https and // links
// we also do not consider internal links as exit intent
if (href && (href.indexOf('http') === 0 || href.indexOf('//') === 0) && isExternal(href)) {
addEventListener(link, 'click', this.handleLinkClick.bind(this), {
passive: false, capture: false, once: true
});
}
}
}
// do we detect using the unload interactions?
if (this.options.detectUnload) {
addEventListener(window, 'keydown', this.handleKeydown.bind(this), {capture: false, passive: true});
addEventListener(window, 'keyup', this.handleKeyup.bind(this), {capture: false, passive: true});
addEventListener(window, 'beforeunload', this.handleUnload.bind(this), {
capture: false,
passive: true
});
}
}
/**
* What we can do here is that we can take note that the mouse is outside of the viewport
* If the mouse leaves our viewport, then we can assume that the user
* 1. goes to another tab: unload event won't be triggered
* 2. click close: unload event will be triggered
* 3. click back/forward: unload event will also be triggered @#$#@%@#%@#$
* 4. click refresh: unload event will also be triggered
*
* Due to the security model of browsers, we actually have no reliable way to know how the users leave our web page
* If we trigger the exit event here, we actually will capture both the refresh and back/forward as well
*
* @param e
*/
handleMouseLeave(e) {
// ignore if not active
if (! this._active) {
return;
}
// Get the current viewport width.
var vpWidth = Math.max(_html.clientWidth, window.innerWidth || 0);
// If the current mouse X position is within Xpx of the right edge
// of the viewport, return.
if (e.clientX >= (vpWidth - this.options.sensitivityX)) {
return;
}
// If the current mouse Y position is not within Ypx of the top
// edge of the viewport, return.
if (e.clientY >= this.options.sensitivityY) {
return;
}
// Reliable, works on mouse exiting window and
// user switching active program
var from = e.relatedTarget || e.toElement;
if (! from) {
this._unloadable = true;
// this._delayTimer = setTimeout(() => {
// this.triggerExit('mouse', e, this);
// }, this.options.exitDelay);
}
}
handleMouseEnter(e) {
// ignore if not active
if (! this._active) {
return;
}
this._unloadable = false;
// cancel our exit intent since this is just an accident :D
// if (this._delayTimer) {
// clearTimeout(this._delayTimer);
// }
}
/**
* If we click on an external link, we certainly assume exit intent
*
* @param e
*/
handleLinkClick(e) {
// should we ignore the open in new tab actions?
// TODO: certain OS such as IOS has different behavior such as the double tap to open link in new tab
if (this.options.ignoreOpenInNewTab) {
if (e.ctrlKey || e.shiftKey || e.metaKey || // metaKey is for apple
(e.button && e.button == 1) // middle click, >IE9 + everyone else
) {
return;
}
}
if (this.shouldTriggerExit()) {
e.preventDefault();
this.trigger('exit', 'link', e, this);
}
}
handleKeydown(e) {
// ignore if not active
if (! this._active) {
return;
}
if (null == this._unloadTimer) {
// if the shift, ctrl, or mac key was pressed,
// we assume that if the unload event happens within a given amount of time
// then the unload will be either refresh, or close
if (e.shiftKey || e.metaKey || e.ctrlKey) {
this._unloadable = true;
this._unloadTimer = setTimeout(() => {
this._unloadable = false;
this._unloadTimer = null;
}, this.options.unloadSensitivity);
return;
}
// this is the alt key
let vKey = e.keyCode ? e.keyCode : e.which ? e.which : e.charCode;
if (18 == vKey) {
this._unloadable = true;
this._unloadTimer = setTimeout(() => {
this._unloadable = false;
this._unloadTimer = null;
}, this.options.unloadSensitivity);
}
}
}
handleKeyup(e) {
// ignore if not active
if (! this._active) {
return;
}
if (this._unloadTimer) {
if (e.shiftKey || e.metaKey || e.ctrlKey) {
clearTimeout(this._unloadTimer);
this._unloadTimer = null;
}
let vKey = e.keyCode ? e.keyCode : e.which ? e.which : e.charCode;
if (18 == vKey) {
clearTimeout(this._unloadTimer);
this._unloadTimer = null;
}
}
}
handleUnload(e) {
if (this._unloadable && this.shouldTriggerExit()) {
this.trigger('exit', 'unload', e, this);
e.returnValue = this.options.unloadMessage;
return this.options.unloadMessage;
}
}
pause() {
this._active = false;
}
resume() {
this._active = true;
}
shouldTriggerExit() {
if (! this._active) {
return false;
}
if (this.options.ignoreMultipleInstances) {
let instances = parseInt(readCookie('nl-exit-instances') || 1);
if (instances > 1) {
return false;
}
}
return true;
}
destroy() {
// do we detect using mouse movements?
if (this.options.detectMouse) {
removeEventListener(_html, 'mouseenter', this.handleMouseEnter.bind(this), {
capture: false, passive: true
});
removeEventListener(_html, 'mouseleave', this.handleMouseLeave.bind(this), {
capture: false, passive: true
});
}
// do we detect using the link interactions?
if (this.options.detectLink) {
let links;
if (this.options.excludeSelector) {
links = _html.querySelectorAll('a:not(' + this.options.excludeSelector + ')');
} else {
links = _html.getElementsByTagName('a');
}
for (let link of links) {
let href = link.getAttribute('href');
if (href && href.indexOf('mailto') !== 0 && isExternal(href)) {
removeEventListener(link, 'click', this.handleLinkClick.bind(this), {
passive: false, capture: false, once: true
});
}
}
}
// do we detect using the unload interactions?
if (this.options.detectUnload) {
removeEventListener(window, 'keydown', this.handleKeydown.bind(this), {
capture: false, passive: true
});
removeEventListener(window, 'keyup', this.handleKeyup.bind(this), {capture: false, passive: true});
removeEventListener(window, 'beforeunload', this.handleUnload.bind(this), {
capture: false, passive: true
});
}
}
trigger(hook, ...args) {
if ('function' === typeof this.options[hook]) {
this.options[hook].apply(this, args);
}
}
}
root.NuExitIntent = ExitIntent;
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment