Реалізація Time-delayed Popup на сайті
Time-delayed popup — найпростіший вид спливаючого вікна: з'являється через N секунд після завантаження сторінки. Основний ризик — показати занадто рано, коли користувач ще не зрозумів контекст сторінки. П'ять-сім секунд — мінімальний розумний поріг для більшості сценаріїв.
Базова реалізація
// time-popup.ts
interface TimePopupConfig {
delayMs: number;
cooldownKey: string; // ключ в localStorage
cooldownMs?: number; // 0 = показати один раз назавжди
onShow: () => void;
}
export function schedulePopup(config: TimePopupConfig): () => void {
const { delayMs, cooldownKey, cooldownMs, onShow } = config;
// Перевіряємо, потрібно показувати
const lastShown = localStorage.getItem(cooldownKey);
if (lastShown) {
if (!cooldownMs) return () => {}; // показали один раз, більше не потрібно
if (Date.now() - Number(lastShown) < cooldownMs) return () => {};
}
const timer = setTimeout(() => {
// Додаткова перевірка: користувач все ще на сторінці
if (document.hidden) return;
localStorage.setItem(cooldownKey, String(Date.now()));
onShow();
}, delayMs);
return () => clearTimeout(timer);
}
Спливаюче вікно з кількома варіантами контенту (A/B)
// DelayedPopup.tsx
import { useEffect, useRef, useState } from 'react';
import { schedulePopup } from './time-popup';
type PopupVariant = 'discount' | 'lead-magnet' | 'callback';
const VARIANTS: Record<PopupVariant, {
headline: string;
body: string;
cta: string;
}> = {
discount: {
headline: 'Знижка 10% на перший заказ',
body: 'Введіть email — відправимо промокод відразу.',
cta: 'Отримати знижку',
},
'lead-magnet': {
headline: 'Безплатний чеклист',
body: '15 пунктів, які ми перевіряємо на кожному проекті.',
cta: 'Завантажити PDF',
},
callback: {
headline: 'Залишилися питання?',
body: 'Залиште номер — передзвонимо протягом 15 хвилин.',
cta: 'Чекаю дзвінка',
},
};
// Простий детермінований A/B: ділимо за останньою цифрою дати
function pickVariant(): PopupVariant {
const bucket = new Date().getDate() % 3;
return (['discount', 'lead-magnet', 'callback'] as PopupVariant[])[bucket];
}
export function DelayedPopup() {
const [open, setOpen] = useState(false);
const [variant] = useState<PopupVariant>(pickVariant);
const [value, setValue] = useState('');
const [submitted, setSubmitted] = useState(false);
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const cleanup = schedulePopup({
delayMs: 7000,
cooldownKey: 'delayed_popup_v2',
cooldownMs: 3 * 24 * 60 * 60 * 1000, // раз на 3 дні
onShow: () => {
setOpen(true);
// відстеження показу
window.gtag?.('event', 'popup_shown', {
popup_variant: variant,
page_path: location.pathname,
});
},
});
return cleanup;
}, [variant]);
useEffect(() => {
if (open) {
dialogRef.current?.showModal();
} else {
dialogRef.current?.close();
}
}, [open]);
async function handleSubmit() {
if (!value.trim()) return;
await fetch('/api/popup-lead', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
value,
variant,
source: 'time_delayed_popup',
pageUrl: location.href,
}),
});
window.gtag?.('event', 'popup_converted', { popup_variant: variant });
setSubmitted(true);
setTimeout(() => setOpen(false), 2500);
}
const content = VARIANTS[variant];
const inputType = variant === 'callback' ? 'tel' : 'email';
const placeholder = variant === 'callback' ? '+380 (99) 000-00-00' : '[email protected]';
return (
<dialog
ref={dialogRef}
onClose={() => setOpen(false)}
className="max-w-sm w-full rounded-2xl p-0 shadow-2xl backdrop:bg-black/50 animate-in fade-in slide-in-from-bottom-4"
>
<div className="relative p-7">
<button
onClick={() => setOpen(false)}
aria-label="Закрити"
className="absolute top-3 right-3 w-7 h-7 flex items-center justify-center rounded-full hover:bg-gray-100 text-gray-400"
>
✕
</button>
{submitted ? (
<div className="py-4 text-center">
<div className="text-3xl mb-2">✅</div>
<p className="font-semibold text-gray-800">Готово, чекаємо!</p>
</div>
) : (
<>
<h2 className="text-xl font-bold text-gray-900 mb-1">{content.headline}</h2>
<p className="text-sm text-gray-500 mb-5">{content.body}</p>
<div className="flex gap-2">
<input
type={inputType}
value={value}
onChange={e => setValue(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSubmit()}
placeholder={placeholder}
autoFocus
className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg whitespace-nowrap"
>
{content.cta}
</button>
</div>
<p className="mt-3 text-xs text-gray-400">
Не будемо турбувати частіше ніж раз на тиждень
</p>
</>
)}
</div>
</dialog>
);
}
Ключові деталі
Перевірка document.hidden перед показом важлива: користувач міг відкрити вкладку й одразу перейти на іншу. Таймер відлічиться, але показувати спливаюче вікно неактивній вкладці немає сенсу.
Ключ cooldown в localStorage повинен версіонуватися. При змінені контенту спливаючого вікна змініть ключ (delayed_popup_v2 → delayed_popup_v3), щоб користувачі, які вже бачили стару версію, побачили нову.
<dialog> з нативним showModal() дає автоматичний focus trap та закриття по Escape без додаткового коду. Використовувати div з ручним управлінням фокусом — зайва робота.
Терміни
Один день включаючи A/B-розмітку, відстеження та localStorage-логіку.







