Implementing Exit Intent Popup for User Retention
Exit Intent is a pattern where a popup appears at the moment a user is about to leave a page. On desktop, this is detected by mouse movement toward the top of the viewport (toward the browser's close button or address bar). On mobile devices, it is triggered by pressing the back button or by the visibilitychange event.
Desktop Detection
The key point: respond not to any upward movement, but to rapid movement toward the top of the screen with a small Y-coordinate.
// exit-intent.ts
interface ExitIntentOptions {
threshold?: number; // px from top, default 20
delay?: number; // minimum seconds on page before showing
cooldown?: number; // ms until next trigger (0 = show once)
onExit: () => void;
}
export function createExitIntentDetector(options: ExitIntentOptions) {
const {
threshold = 20,
delay = 3,
cooldown = 0,
onExit,
} = options;
let triggered = false;
let pageEnteredAt = Date.now();
let lastTriggeredAt = 0;
const STORAGE_KEY = 'exit_intent_last_shown';
// Check if we should show based on session/cooldown period
function shouldShow(): boolean {
if (triggered && cooldown === 0) return false;
if (Date.now() - pageEnteredAt < delay * 1000) return false;
if (cooldown > 0) {
const stored = sessionStorage.getItem(STORAGE_KEY);
if (stored && Date.now() - Number(stored) < cooldown) return false;
}
return true;
}
function handleMouseLeave(e: MouseEvent) {
// Moving toward top edge
if (e.clientY > threshold) return;
// Fast enough (movement speed — position derivative)
if (!shouldShow()) return;
triggered = true;
lastTriggeredAt = Date.now();
if (cooldown > 0) {
sessionStorage.setItem(STORAGE_KEY, String(lastTriggeredAt));
}
onExit();
}
// Mobile fallback: visibilitychange
function handleVisibilityChange() {
if (document.visibilityState === 'hidden' && shouldShow()) {
triggered = true;
onExit();
}
}
document.addEventListener('mouseleave', handleMouseLeave);
document.addEventListener('visibilitychange', handleVisibilityChange);
return {
reset() {
triggered = false;
sessionStorage.removeItem(STORAGE_KEY);
},
destroy() {
document.removeEventListener('mouseleave', handleMouseLeave);
document.removeEventListener('visibilitychange', handleVisibilityChange);
},
};
}
Popup Component (React)
// ExitIntentPopup.tsx
import { useEffect, useRef, useState } from 'react';
import { createExitIntentDetector } from './exit-intent';
interface ExitIntentPopupProps {
headline: string;
subtext: string;
ctaLabel: string;
onCta: () => void;
offer?: string; // e.g., "10% discount with code EXIT10"
delaySeconds?: number;
}
export function ExitIntentPopup({
headline,
subtext,
ctaLabel,
onCta,
offer,
delaySeconds = 5,
}: ExitIntentPopupProps) {
const [visible, setVisible] = useState(false);
const [email, setEmail] = useState('');
const dialogRef = useRef<HTMLDialogElement>(null);
const detectorRef = useRef<ReturnType<typeof createExitIntentDetector>>();
useEffect(() => {
detectorRef.current = createExitIntentDetector({
delay: delaySeconds,
cooldown: 24 * 60 * 60 * 1000, // show once per day
onExit: () => setVisible(true),
});
return () => detectorRef.current?.destroy();
}, [delaySeconds]);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (visible) {
dialog.showModal();
// focus on first interactive element
dialog.querySelector<HTMLElement>('input, button')?.focus();
} else {
dialog.close();
}
}, [visible]);
function handleClose() {
setVisible(false);
}
function handleCta() {
onCta();
setVisible(false);
}
function handleBackdropClick(e: React.MouseEvent<HTMLDialogElement>) {
if (e.target === dialogRef.current) handleClose();
}
return (
<dialog
ref={dialogRef}
onClick={handleBackdropClick}
className="rounded-2xl p-0 max-w-md w-full shadow-2xl backdrop:bg-black/50"
>
<div className="p-8">
{/* Close button */}
<button
onClick={handleClose}
aria-label="Close"
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 text-xl leading-none"
>
×
</button>
<h2 className="text-xl font-bold text-gray-900 mb-2">{headline}</h2>
<p className="text-gray-600 text-sm mb-4">{subtext}</p>
{offer && (
<div className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 mb-4">
<p className="text-sm font-medium text-amber-800">{offer}</p>
</div>
)}
<div className="flex gap-2">
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="[email protected]"
className="flex-1 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={handleCta}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700"
>
{ctaLabel}
</button>
</div>
<button
onClick={handleClose}
className="mt-3 text-xs text-gray-400 hover:text-gray-600 w-full text-center"
>
No, I'm leaving
</button>
</div>
</dialog>
);
}
Analytics for Impressions and Conversions
Without measurement, popup optimization is impossible. Minimal event set:
function trackPopupEvent(
event: 'shown' | 'closed' | 'converted',
meta?: Record<string, unknown>
) {
// Google Analytics 4
if (typeof gtag !== 'undefined') {
gtag('event', `exit_intent_${event}`, {
page_path: window.location.pathname,
...meta,
});
}
// Custom analytics
fetch('/api/analytics/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
keepalive: true, // important for events on page close
body: JSON.stringify({
event: `exit_intent.${event}`,
url: window.location.href,
timestamp: new Date().toISOString(),
...meta,
}),
});
}
keepalive: true is critical for fetch in events that may fire when closing a tab — without it, the browser may cancel the request.
Common Implementation Mistakes
Several typical issues:
Popup on every visit — annoying, conversion drops. Need cooldown of at least one day via localStorage/cookie.
Showing on mobile via mouseleave — event doesn't fire. Need separate detector via visibilitychange or pagehide.
Missing <dialog> polyfill — HTMLDialogElement is supported in all modern browsers (Chrome 37+, Firefox 98+, Safari 15.4+), but for older support, use dialog-polyfill or custom implementation via aria-modal.
Timeline
Basic popup with detector and analytics — one to two days. A/B testing multiple variants with automatic winner selection — another two to three days.







