A visitor reads your pricing section, moves the cursor to the tab bar, and is gone. You never learn what they wanted, and you never got an email. An exit intent popup is a last, single chance to ask for that email as the cursor heads for the exit.
It is also easy to get wrong. The desktop signal is one event listener, the mobile signal does not exist, and a popup that fires twice per visit trains people to resent you. This guide builds the whole thing with a native <dialog> and one SDK call, no plugin.
Listen for mouseleave on document.documentElement and open a <dialog> when event.clientY <= 0. Guard it with sessionStorage so it fires once per session, skip it when OperatorStack.isSignedUp() is true, and submit with OperatorStack.joinWaitlist({ email }). Touch devices never fire mouseleave, so use a fast upward-scroll fallback there.
Build the Dialog
The modal itself is covered in How to Trigger a Waitlist Modal From Any Button: keep the markup permanently in the DOM and reveal it with showModal(). This version builds the form yourself with the SDK, so it works even when your hero already holds the one allowed waitlist widget.
<dialog id="exit-modal">
<form id="exit-form">
<h2>Before you go</h2>
<p>Get early access and a link to move up the list.</p>
<input type="email" name="email" placeholder="you@company.com" required />
<button type="submit">Join the waitlist</button>
<button type="button" id="exit-close">No thanks</button>
</form>
<p id="exit-done" hidden>You are on the list.</p>
</dialog>
<script src="https://operatorstack.dev/os.js" data-project="pk_your_project_key"></script>
Detect the Exit
Exit intent on desktop means the cursor left the page through the top edge. Sides and bottom do not count: a cursor leaving toward the scrollbar is not heading for the tab bar.
<script>
const modal = document.getElementById("exit-modal");
const KEY = "os_exit_shown";
function showExit(trigger) {
if (sessionStorage.getItem(KEY)) return;
if (window.OperatorStack && window.OperatorStack.isSignedUp()) return;
sessionStorage.setItem(KEY, "1");
modal.showModal();
window.OperatorStack.trackEvent("exit_popup_shown", { trigger });
}
document.documentElement.addEventListener("mouseleave", (e) => {
if (e.clientY <= 0) showExit("mouseleave");
});
</script>
Three guards run before the dialog opens. The sessionStorage flag caps it at one showing per session. isSignedUp() skips anyone who already joined. Setting the flag before showModal() means a rapid second event cannot reopen it.
Do not arm the listener on page load for a visitor who has been there two seconds. Wrap it in a setTimeout of 10 to 15 seconds, or require some scroll depth first. Otherwise a visitor who lands, moves the cursor up to check the URL, and gets a popup has seen it before reading a single word.
Submit With the SDK
<script>
const form = document.getElementById("exit-form");
form.addEventListener("submit", async (e) => {
e.preventDefault();
try {
await window.OperatorStack.joinWaitlist({ email: form.email.value });
form.hidden = true;
document.getElementById("exit-done").hidden = false;
window.OperatorStack.trackEvent("exit_popup_converted");
} catch (err) {
form.querySelector("button[type=submit]").textContent = "Try again";
}
});
document.getElementById("exit-close").addEventListener("click", () => modal.close());
</script>
joinWaitlist returns { success, referral_code, referral_link } and awaits SDK readiness itself, so a fast submit before os.js finishes loading still works. It also credits a stored ?ref= code automatically.
The Mobile Fallback
Touch devices have no cursor, so mouseleave never fires and the popup above silently does nothing on mobile. The closest proxy is weaker.
<script>
// Fast upward scroll after reading at least 50% of the page
let lastY = window.scrollY;
window.addEventListener(
"scroll",
() => {
const y = window.scrollY;
const depth = (y + innerHeight) / document.documentElement.scrollHeight;
if (lastY - y > 80 && depth > 0.5) showExit("scroll_up");
lastY = y;
},
{ passive: true },
);
</script>
A fast upward flick after reading half the page is the closest touch equivalent to reaching for the tab bar.
Because showExit records trigger in trackEvent, you can compare mouseleave against scroll_up conversion in the dashboard. If scroll-up converts far worse, turn it off for mobile instead of tuning it.
Measure Whether It Earns Its Place
You now log exit_popup_shown and exit_popup_converted. Divide converted by shown for the popup's rate, then check that total signups rose rather than just shifting from the hero form. To test it cleanly, follow the split pattern in How to A/B Test Your Waitlist Page and show the popup to half of sessions. A popup that converts 3 percent of exits but lowers hero signups is not adding anything.
Frequently Asked Questions
How do I detect exit intent with JavaScript?
Listen for mouseleave on document.documentElement and fire only when event.clientY is 0 or less, meaning the cursor left through the top edge. Sides and bottom do not count. This works on desktop only.
Does exit intent work on mobile?
No. Touch devices have no cursor, so mouseleave never fires. Use a fast upward scroll after the visitor has read past a depth threshold (a tab-hidden event cannot show a dialog). It is a weaker signal, so cap it tightly.
Will an exit intent popup hurt my SEO?
Google's interstitial guidance targets popups that cover content right after arrival from search. A popup shown once per session as the visitor leaves is not that. Do not show it on page load.
How do I stop the popup from showing to people who already joined?
Check window.OperatorStack.isSignedUp() before opening. It reads local storage, returns a boolean with no network call, and is true when the visitor has a stored signup for your project.
Should I use the widget div or joinWaitlist() inside the popup?
Use joinWaitlist() if the page already has a waitlist widget, because only the first data-os-widget="waitlist" div renders. If the popup is your only form, a permanently mounted widget div inside the dialog also works.