Behavior-Triggered Popup Implementation
Triggered popup is a modal that appears not on timer but on specific user action: mouse moving toward tab close, prolonged pause on certain block, scroll to page bottom. Correctly configured popup increases conversion 2-5%, incorrectly configured annoys and drives people away.
Types of Triggers
Main triggers worth implementing:
- Exit intent — mouse accelerates toward top edge, user about to close tab
- Scroll depth — scroll reached N% of page
- Time on page — user spent X seconds on page
- Inactivity — no mouse movement and clicks for Y seconds
- Element visibility — specific block entered viewport
- Click intent — hover over element without clicking
Exit Intent
Most effective trigger for landing pages. Determined by cursor speed and direction:
class ExitIntentDetector {
private threshold = 10; // pixels from top
private sensitivity = 50; // px/ms — minimum upward movement speed
private triggered = false;
private lastY = 0;
private lastTime = 0;
constructor(private onExit: () => void) {
document.addEventListener('mousemove', this.handleMouseMove.bind(this));
}
private handleMouseMove(e: MouseEvent): void {
if (this.triggered) return;
const now = Date.now();
const deltaY = e.clientY - this.lastY;
const deltaTime = now - this.lastTime;
const velocityY = deltaY / deltaTime; // px/ms, negative = upward
if (e.clientY < this.threshold && velocityY < -this.sensitivity) {
this.triggered = true;
this.onExit();
}
this.lastY = e.clientY;
this.lastTime = now;
}
reset(): void {
this.triggered = false;
}
}
// Usage
const detector = new ExitIntentDetector(() => {
showPopup('exit_offer');
});
On mobile, exit intent doesn't work—no mousemove event. Use back button press via popstate instead:
// Mobile exit: push state to history, catch exit
history.pushState({ popup: true }, '');
window.addEventListener('popstate', (e) => {
if (!e.state?.popup) {
showPopup('mobile_exit_offer');
history.pushState({ popup: true }, '');
}
});
Scroll Depth Trigger
function onScrollDepth(percentage: number, callback: () => void): () => void {
let fired = false;
const handler = () => {
if (fired) return;
const scrolled = window.scrollY + window.innerHeight;
const total = document.documentElement.scrollHeight;
if (scrolled / total >= percentage / 100) {
fired = true;
callback();
}
};
window.addEventListener('scroll', handler, { passive: true });
return () => window.removeEventListener('scroll', handler);
}
// Popup at 70% scroll
onScrollDepth(70, () => showPopup('mid_page_offer'));
Element Visibility (Intersection Observer)
Show popup when user scrolls to pricing block:
function onElementVisible(selector: string, callback: () => void): void {
const el = document.querySelector(selector);
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
observer.disconnect();
callback();
}
},
{ threshold: 0.5 } // element visible 50%
);
observer.observe(el);
}
onElementVisible('#pricing-section', () => showPopup('pricing_helper'));
Popup State Manager
Critical to not show one popup multiple times and not show several at once:
class PopupManager {
private shown = new Set<string>(
JSON.parse(localStorage.getItem('shown_popups') ?? '[]')
);
private currentPopup: string | null = null;
canShow(id: string, cooldownDays = 7): boolean {
if (this.currentPopup) return false; // another already open
const key = `popup_shown_${id}`;
const lastShown = localStorage.getItem(key);
if (!lastShown) return true;
const daysSince = (Date.now() - parseInt(lastShown)) / 86400000;
return daysSince >= cooldownDays;
}
show(id: string): void {
if (!this.canShow(id)) return;
this.currentPopup = id;
localStorage.setItem(`popup_shown_${id}`, Date.now().toString());
this.shown.add(id);
localStorage.setItem('shown_popups', JSON.stringify([...this.shown]));
document.getElementById(`popup-${id}`)?.classList.add('popup--visible');
document.body.classList.add('popup-open');
}
close(id: string): void {
this.currentPopup = null;
document.getElementById(`popup-${id}`)?.classList.remove('popup--visible');
document.body.classList.remove('popup-open');
}
}
const popups = new PopupManager();
function showPopup(id: string) { popups.show(id); }
Tracking Impressions and Conversions
Each popup must record impressions, clicks, and closes:
document.querySelectorAll('[data-popup]').forEach(popup => {
const id = popup.getAttribute('data-popup')!;
// Impression on open
const observer = new MutationObserver(() => {
if (popup.classList.contains('popup--visible')) {
gtag('event', 'popup_impression', { popup_id: id });
}
});
observer.observe(popup, { attributes: true, attributeFilter: ['class'] });
// CTA click
popup.querySelector('[data-cta]')?.addEventListener('click', () => {
gtag('event', 'popup_cta_click', { popup_id: id });
});
// Close
popup.querySelector('[data-close]')?.addEventListener('click', () => {
gtag('event', 'popup_close', { popup_id: id });
popups.close(id);
});
});
Timeline
One popup with exit intent and timer: 3-5 hours. Full manager with 4-6 triggers, anti-spam, tracking: 1-2 days. ESP integration for email capture from popup: 2-4 more hours.







