Реалізація Exit Intent Popup для утримання користувачів
Exit Intent — це паттерн, при якому спливаюче вікно з'являється в момент, коли користувач збирається покинути сторінку. На настільних комп'ютерах це виявляється за рухом мишки до верхньої границі вікна переглядача (до кнопки закриття вкладки або адресного рядка). На мобільних пристроях — за натисненням кнопки «назад» або за подією visibilitychange.
Виявлення на настільних комп'ютерах
Ключовий момент: реагувати не на будь-який рух вгору, а на швидкий рух до верху екрану з малою Y-координатою.
// exit-intent.ts
interface ExitIntentOptions {
threshold?: number; // px від верху, за замовчуванням 20
delay?: number; // мінімум секунд на сторінці перед показом
cooldown?: number; // ms до наступного показу (0 = показати один раз)
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';
// Перевірте, чи не показували вже в цій сесії / за період cooldown
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) {
// Рухаємось до верхнього краю
if (e.clientY > threshold) return;
// Достатньо швидко (швидкість руху — похідна позиції)
if (!shouldShow()) return;
triggered = true;
lastTriggeredAt = Date.now();
if (cooldown > 0) {
sessionStorage.setItem(STORAGE_KEY, String(lastTriggeredAt));
}
onExit();
}
// Мобільна заміна: 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);
},
};
}
Компонент спливаючого вікна (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; // наприклад, "Знижка 10% за кодом 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, // показувати один раз на день
onExit: () => setVisible(true),
});
return () => detectorRef.current?.destroy();
}, [delaySeconds]);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (visible) {
dialog.showModal();
// фокус на першому інтерактивному елементі
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">
{/* Кнопка закриття */}
<button
onClick={handleClose}
aria-label="Закрити"
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.com"
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"
>
Ні, я йду
</button>
</div>
</dialog>
);
}
Аналітика показів і конверсій
Без вимірювань оптимізація спливаючого вікна неможлива. Мінімальний набір подій:
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,
});
}
// власна аналітика
fetch('/api/analytics/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
keepalive: true, // важливо для подій при закритті вкладки
body: JSON.stringify({
event: `exit_intent.${event}`,
url: window.location.href,
timestamp: new Date().toISOString(),
...meta,
}),
});
}
keepalive: true критично важливий для fetch у подіях, які можуть спрацьовувати при закритті вкладки — без цього браузер може скасувати запит.
Типові помилки при реалізації
Кілька типових проблем:
Спливаюче вікно при кожному відвідуванні — дратує, конверсія падає. Потрібен cooldown мінімум на день через localStorage/cookie.
Показ на мобільних через mouseleave — подія не спрацьовує. Потрібен окремий детектор через visibilitychange або pagehide.
Відсутність полізаповнення <dialog> — HTMLDialogElement підтримується у всіх сучасних браузерах (Chrome 37+, Firefox 98+, Safari 15.4+), але для підтримки старіших — використовуйте dialog-polyfill або користувацьку реалізацію через aria-modal.
Строки виконання
Базове спливаюче вікно з детектором і аналітикою — один-два дні. A/B тестування кількох варіантів з автоматичним вибором переможця — ще два-три дні.







