Implementing Time-delayed Popup on Website
A time-delayed popup is the simplest type of popup: it appears N seconds after the page loads. The main risk is showing it too early, when the user hasn't yet understood the page context. Five to seven seconds is the minimum reasonable threshold for most scenarios.
Basic Implementation
// time-popup.ts
interface TimePopupConfig {
delayMs: number;
cooldownKey: string; // localStorage key
cooldownMs?: number; // 0 = show once forever
onShow: () => void;
}
export function schedulePopup(config: TimePopupConfig): () => void {
const { delayMs, cooldownKey, cooldownMs, onShow } = config;
// Check if we should show it
const lastShown = localStorage.getItem(cooldownKey);
if (lastShown) {
if (!cooldownMs) return () => {}; // showed once, no need again
if (Date.now() - Number(lastShown) < cooldownMs) return () => {};
}
const timer = setTimeout(() => {
// Additional check: user is still on the page
if (document.hidden) return;
localStorage.setItem(cooldownKey, String(Date.now()));
onShow();
}, delayMs);
return () => clearTimeout(timer);
}
Popup with Multiple Content Variants (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% off your first order',
body: 'Enter your email — we\'ll send the promo code immediately.',
cta: 'Get discount',
},
'lead-magnet': {
headline: 'Free checklist',
body: '15 points we check on every project.',
cta: 'Download PDF',
},
callback: {
headline: 'Have questions?',
body: 'Leave your number — we\'ll call back within 15 minutes.',
cta: 'Call me back',
},
};
// Simple deterministic A/B: divide by last digit of date
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, // once every 3 days
onShow: () => {
setOpen(true);
// track impression
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' ? '+1 (999) 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="Close"
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">Done, we're waiting!</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">
We won't bother you more than once a week
</p>
</>
)}
</div>
</dialog>
);
}
Key Details
The document.hidden check before showing is important: the user might have opened a tab and immediately switched to another. The timer will count down, but showing a popup to an inactive tab is pointless.
The cooldown key in localStorage should be versioned. When changing popup content, change the key (delayed_popup_v2 → delayed_popup_v3) so that users who've already seen the old version will see the new one.
A native <dialog> with showModal() provides automatic focus trap and Escape-key closing without additional code. Using a div with manual focus management is extra work.
Timeline
One day including A/B markup, tracking, and localStorage logic.







