[{"data":1,"prerenderedAt":330},["ShallowReactive",2],{"blog-how-to\u002Fexit-intent-waitlist":3},{"id":4,"title":5,"body":6,"category":299,"date":300,"dateModified":301,"description":302,"draft":303,"extension":304,"faq":305,"featured":303,"keywords":316,"meta":317,"navigation":318,"ogDescription":319,"ogTitle":320,"path":321,"readTime":322,"schemaType":323,"seo":324,"sitemap":325,"stem":326,"tags":327,"twitterCard":328,"__hash__":329},"blog\u002Fblog\u002Fhow-to\u002Fexit-intent-waitlist.md","Exit Intent Waitlist Popup: How to Build One Without a Plugin",{"type":7,"value":8,"toc":291},"minimark",[9,13,21,57,62,75,85,89,92,98,111,121,125,131,149,153,159,165,168,193,197,213,270],[10,11,12],"p",{},"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.",[10,14,15,16,20],{},"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 ",[17,18,19],"code",{},"\u003Cdialog>"," and one SDK call, no plugin.",[22,23,24],"tldr",{},[10,25,26,27,30,31,34,35,37,38,41,42,45,46,49,50,53,54,56],{},"Listen for ",[17,28,29],{},"mouseleave"," on ",[17,32,33],{},"document.documentElement"," and open a ",[17,36,19],{}," when ",[17,39,40],{},"event.clientY \u003C= 0",". Guard it with ",[17,43,44],{},"sessionStorage"," so it fires once per session, skip it when ",[17,47,48],{},"OperatorStack.isSignedUp()"," is true, and submit with ",[17,51,52],{},"OperatorStack.joinWaitlist({ email })",". Touch devices never fire ",[17,55,29],{},", so use a fast upward-scroll fallback there.",[58,59,61],"h2",{"id":60},"build-the-dialog","Build the Dialog",[10,63,64,65,70,71,74],{},"The modal itself is covered in ",[66,67,69],"a",{"href":68},"\u002Fblog\u002Fhow-to\u002Ftrigger-waitlist-modal","How to Trigger a Waitlist Modal From Any Button",": keep the markup permanently in the DOM and reveal it with ",[17,72,73],{},"showModal()",". This version builds the form yourself with the SDK, so it works even when your hero already holds the one allowed waitlist widget.",[76,77,83],"pre",{"className":78,"code":80,"language":81,"meta":82},[79],"language-html","\u003Cdialog id=\"exit-modal\">\n  \u003Cform id=\"exit-form\">\n    \u003Ch2>Before you go\u003C\u002Fh2>\n    \u003Cp>Get early access and a link to move up the list.\u003C\u002Fp>\n    \u003Cinput type=\"email\" name=\"email\" placeholder=\"you@company.com\" required \u002F>\n    \u003Cbutton type=\"submit\">Join the waitlist\u003C\u002Fbutton>\n    \u003Cbutton type=\"button\" id=\"exit-close\">No thanks\u003C\u002Fbutton>\n  \u003C\u002Fform>\n  \u003Cp id=\"exit-done\" hidden>You are on the list.\u003C\u002Fp>\n\u003C\u002Fdialog>\n\n\u003Cscript src=\"https:\u002F\u002Foperatorstack.dev\u002Fos.js\" data-project=\"pk_your_project_key\">\u003C\u002Fscript>\n","html","",[17,84,80],{"__ignoreMap":82},[58,86,88],{"id":87},"detect-the-exit","Detect the Exit",[10,90,91],{},"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.",[76,93,96],{"className":94,"code":95,"language":81,"meta":82},[79],"\u003Cscript>\n  const modal = document.getElementById(\"exit-modal\");\n  const KEY = \"os_exit_shown\";\n\n  function showExit(trigger) {\n    if (sessionStorage.getItem(KEY)) return;\n    if (window.OperatorStack && window.OperatorStack.isSignedUp()) return;\n    sessionStorage.setItem(KEY, \"1\");\n    modal.showModal();\n    window.OperatorStack.trackEvent(\"exit_popup_shown\", { trigger });\n  }\n\n  document.documentElement.addEventListener(\"mouseleave\", (e) => {\n    if (e.clientY \u003C= 0) showExit(\"mouseleave\");\n  });\n\u003C\u002Fscript>\n",[17,97,95],{"__ignoreMap":82},[10,99,100,101,103,104,107,108,110],{},"Three guards run before the dialog opens. The ",[17,102,44],{}," flag caps it at one showing per session. ",[17,105,106],{},"isSignedUp()"," skips anyone who already joined. Setting the flag before ",[17,109,73],{}," means a rapid second event cannot reopen it.",[112,113,114],"warning-box",{},[10,115,116,117,120],{},"Do not arm the listener on page load for a visitor who has been there two seconds. Wrap it in a ",[17,118,119],{},"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.",[58,122,124],{"id":123},"submit-with-the-sdk","Submit With the SDK",[76,126,129],{"className":127,"code":128,"language":81,"meta":82},[79],"\u003Cscript>\n  const form = document.getElementById(\"exit-form\");\n\n  form.addEventListener(\"submit\", async (e) => {\n    e.preventDefault();\n    try {\n      await window.OperatorStack.joinWaitlist({ email: form.email.value });\n      form.hidden = true;\n      document.getElementById(\"exit-done\").hidden = false;\n      window.OperatorStack.trackEvent(\"exit_popup_converted\");\n    } catch (err) {\n      form.querySelector(\"button[type=submit]\").textContent = \"Try again\";\n    }\n  });\n\n  document.getElementById(\"exit-close\").addEventListener(\"click\", () => modal.close());\n\u003C\u002Fscript>\n",[17,130,128],{"__ignoreMap":82},[10,132,133,136,137,140,141,144,145,148],{},[17,134,135],{},"joinWaitlist"," returns ",[17,138,139],{},"{ success, referral_code, referral_link }"," and awaits SDK readiness itself, so a fast submit before ",[17,142,143],{},"os.js"," finishes loading still works. It also credits a stored ",[17,146,147],{},"?ref="," code automatically.",[58,150,152],{"id":151},"the-mobile-fallback","The Mobile Fallback",[10,154,155,156,158],{},"Touch devices have no cursor, so ",[17,157,29],{}," never fires and the popup above silently does nothing on mobile. The closest proxy is weaker.",[76,160,163],{"className":161,"code":162,"language":81,"meta":82},[79],"\u003Cscript>\n  \u002F\u002F Fast upward scroll after reading at least 50% of the page\n  let lastY = window.scrollY;\n  window.addEventListener(\n    \"scroll\",\n    () => {\n      const y = window.scrollY;\n      const depth = (y + innerHeight) \u002F document.documentElement.scrollHeight;\n      if (lastY - y > 80 && depth > 0.5) showExit(\"scroll_up\");\n      lastY = y;\n    },\n    { passive: true },\n  );\n\u003C\u002Fscript>\n",[17,164,162],{"__ignoreMap":82},[10,166,167],{},"A fast upward flick after reading half the page is the closest touch equivalent to reaching for the tab bar.",[169,170,171],"tip-box",{},[10,172,173,174,177,178,181,182,185,186,188,189,192],{},"Because ",[17,175,176],{},"showExit"," records ",[17,179,180],{},"trigger"," in ",[17,183,184],{},"trackEvent",", you can compare ",[17,187,29],{}," against ",[17,190,191],{},"scroll_up"," conversion in the dashboard. If scroll-up converts far worse, turn it off for mobile instead of tuning it.",[58,194,196],{"id":195},"measure-whether-it-earns-its-place","Measure Whether It Earns Its Place",[10,198,199,200,203,204,207,208,212],{},"You now log ",[17,201,202],{},"exit_popup_shown"," and ",[17,205,206],{},"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 ",[66,209,211],{"href":210},"\u002Fblog\u002Fhow-to\u002Fab-testing-waitlist","How to A\u002FB 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.",[214,215,216,231,240,246,256],"faq-section",{},[217,218,220],"faq-item",{"question":219},"How do I detect exit intent with JavaScript?",[10,221,26,222,30,224,226,227,230],{},[17,223,29],{},[17,225,33],{}," and fire only when ",[17,228,229],{},"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.",[217,232,234],{"question":233},"Does exit intent work on mobile?",[10,235,236,237,239],{},"No. Touch devices have no cursor, so ",[17,238,29],{}," 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.",[217,241,243],{"question":242},"Will an exit intent popup hurt my SEO?",[10,244,245],{},"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.",[217,247,249],{"question":248},"How do I stop the popup from showing to people who already joined?",[10,250,251,252,255],{},"Check ",[17,253,254],{},"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.",[217,257,259],{"question":258},"Should I use the widget div or joinWaitlist() inside the popup?",[10,260,261,262,265,266,269],{},"Use ",[17,263,264],{},"joinWaitlist()"," if the page already has a waitlist widget, because only the first ",[17,267,268],{},"data-os-widget=\"waitlist\""," div renders. If the popup is your only form, a permanently mounted widget div inside the dialog also works.",[271,272,273,274],"content-related-articles",{},"\n  ",[275,276,273,278],"contentrelatedcard",{"href":68,"title":69,"description":277},"The native dialog pattern and the one-time widget scan that decides which modal setups render.",[275,279,273,281],{"href":210,"title":211,"description":280},"Split your traffic to check the popup adds signups instead of moving them from the hero form.",[275,282,286],{"href":283,"title":284,"description":285},"\u002Fblog\u002Fhow-to\u002Fcustom-events-analytics","Custom Events for Product Analytics: Track What Matters Before Launch","Log popup shown and converted events with one line of JavaScript using trackEvent().",[287,288],"cta-box",{"href":289,"label":290},"\u002F","Get Started Free",{"title":82,"searchDepth":292,"depth":292,"links":293},2,[294,295,296,297,298],{"id":60,"depth":292,"text":61},{"id":87,"depth":292,"text":88},{"id":123,"depth":292,"text":124},{"id":151,"depth":292,"text":152},{"id":195,"depth":292,"text":196},"how-to","2026-11-03",null,"Build an exit intent waitlist popup with a native dialog, a mouseleave listener, and the OperatorStack SDK. Includes the mobile fallback and a once-per-session cap.",false,"md",[306,308,310,312,314],{"question":219,"answer":307},"Listen for mouseleave on document.documentElement and fire only when event.clientY is 0 or less, which means the cursor left through the top edge toward the tab bar or address bar. Leaving through the sides or bottom does not count. This works on desktop only.",{"question":233,"answer":309},"No. Touch devices have no cursor, so mouseleave never fires. Use a fast upward scroll after the visitor has scrolled past a depth threshold. It is a weaker signal than a cursor heading for the tab bar, so cap it tightly.",{"question":242,"answer":311},"Google's interstitial guidance targets popups that cover the main content right after a visitor arrives from search. A popup that appears only as the visitor is leaving, and only once per session, is not an intrusive interstitial in that sense. Do not show it on page load.",{"question":248,"answer":313},"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.",{"question":258,"answer":315},"Use joinWaitlist() if the page already has a waitlist widget, because only the first data-os-widget=waitlist div on a page renders. If the popup is your only waitlist form, a permanently mounted widget div inside the dialog also works.","exit intent waitlist popup,exit intent popup javascript,waitlist popup,mouseleave exit intent,mobile exit intent,operatorstack joinwaitlist",{},true,"Desktop exit intent is one mouseleave listener. Mobile has no cursor, so it needs a different trigger. Here is both, wired to a waitlist.","Exit Intent Waitlist Popup Without a Plugin","\u002Fblog\u002Fhow-to\u002Fexit-intent-waitlist","7 min","HowTo",{"title":5,"description":302},{"loc":321},"blog\u002Fhow-to\u002Fexit-intent-waitlist",[],"summary_large_image","Y6BLwje6NPmbxx5eszziKnA6SDbLEk4qhXhRvVsZ16U",1793705066374]