Exit Intent Popup Survey Implementation
Exit intent popup appears when a user is about to leave the page—cursor moves toward the top of the screen (browser) or user presses back button (mobile). Used for retention (discount offer) or feedback collection (why are you leaving?).
Exit Intent Detection
// hooks/useExitIntent.ts
interface UseExitIntentOptions {
threshold?: number; // px from top, default 20
delay?: number; // ms delay before detector activates
onExitIntent: () => void;
}
export function useExitIntent({ threshold = 20, delay = 3000, onExitIntent }: UseExitIntentOptions) {
const triggered = useRef(false);
useEffect(() => {
let enabled = false;
const timer = setTimeout(() => { enabled = true; }, delay);
const handleMouseLeave = (e: MouseEvent) => {
if (!enabled || triggered.current) return;
if (e.clientY <= threshold) {
triggered.current = true;
onExitIntent();
}
};
// Mobile: detection via popstate (back button)
const handlePopState = () => {
if (!triggered.current) {
triggered.current = true;
history.pushState(null, '', location.href); // Cancel navigation
onExitIntent();
}
};
// For mobile — add history record
history.pushState(null, '', location.href);
window.addEventListener('popstate', handlePopState);
document.addEventListener('mouseleave', handleMouseLeave);
return () => {
clearTimeout(timer);
document.removeEventListener('mouseleave', handleMouseLeave);
window.removeEventListener('popstate', handlePopState);
};
}, [threshold, delay, onExitIntent]);
}
Popup with Survey
// ExitIntentPopup.tsx
const EXIT_QUESTIONS = [
{ id: 'reason', text: 'Why are you leaving?', options: [
'Cannot find needed feature',
'Too expensive',
'Difficult to understand',
'Just browsing',
'Other',
]},
];
export function ExitIntentPopup() {
const [visible, setVisible] = useState(false);
const [reason, setReason] = useState('');
const [done, setDone] = useState(false);
// Don't show if already shown in this session
const alreadyShown = sessionStorage.getItem('exit_popup_shown');
useExitIntent({
delay: 5000,
onExitIntent: () => {
if (!alreadyShown) {
setVisible(true);
sessionStorage.setItem('exit_popup_shown', '1');
}
},
});
const submit = async () => {
if (!reason) return;
await fetch('/api/exit-intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason, page: window.location.pathname }),
});
setDone(true);
setTimeout(() => setVisible(false), 2000);
};
if (!visible) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-8 max-w-md w-full shadow-2xl">
<button onClick={() => setVisible(false)} className="absolute top-4 right-4 text-gray-400">✕</button>
{done ? (
<p className="text-center text-green-600 font-medium py-4">Thank you for your answer!</p>
) : (
<>
<h3 className="text-xl font-bold mb-2">Wait!</h3>
<p className="text-gray-600 mb-4 text-sm">Before you go—help us get better.</p>
<p className="font-medium mb-3">Why are you leaving?</p>
<div className="space-y-2">
{EXIT_QUESTIONS[0].options.map(opt => (
<label key={opt} className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="reason" value={opt}
onChange={() => setReason(opt)} className="accent-blue-600" />
<span className="text-sm">{opt}</span>
</label>
))}
</div>
<button onClick={submit} disabled={!reason}
className="mt-4 w-full bg-blue-600 disabled:bg-gray-300 text-white rounded-lg py-2 text-sm">
Submit
</button>
</>
)}
</div>
</div>
);
}
Backend: Saving and Analysis
// ExitIntentController
public function store(Request $request): JsonResponse
{
$request->validate(['reason' => 'required|string|max:200', 'page' => 'nullable|string']);
ExitIntentResponse::create([
'reason' => $request->reason,
'page' => $request->input('page'),
'user_id' => auth()->id(),
'session' => $request->session()->getId(),
]);
return response()->json(['success' => true]);
}
Analysis by page helps find bottlenecks: if 40% leave pricing page because "Too expensive"—work on positioning or add price comparison.
Timeline
Exit intent detector (desktop + mobile), popup with survey, response saving: 2-3 business days.







