Blog · · 11 min
Exit-intent email capture, without a library
The detection is about fifteen lines: a mouseout with no relatedTarget and a clientY at or above zero. On a phone there is no exit intent at all, so the honest mobile trigger is scroll depth or idle time rather than a modal on arrival, which Google treats as an intrusive interstitial. Build the dialog on the native element, remember the dismissal, and never fire it on a checkout page.
By uxgen
Exit intent on a desktop browser is one event and two guards. The pointer leaves the document through the top edge, which is where the tab bar, the address bar and the close button are, and nothing else in the window received it.
document.addEventListener('mouseout', (e) => {
if (e.relatedTarget) return // moved to another element, not out of the window
if (e.clientY > 0) return // left sideways or downwards: not the close button
fire()
})
That is the whole detection. Everything a 40 kilobyte third-party script adds on top of it is scheduling, storage, and an analytics call to somebody else's server.
The part worth more than the snippet is the next sentence: on a phone, exit intent does not exist. There is no pointer to leave through the top edge. The buyer switches apps, pulls down the notification shade or presses a hardware gesture, and none of it reaches your page in time to be useful. Firing a modal on mobile because a desktop technique was ported there is the mistake, and it has a ranking cost attached.
Why is the mobile answer not a popup?
Two reasons, and the second one is not about taste.
There is no signal. The events people reach for (visibilitychange, pagehide, beforeunload) fire when the page is already going away or already hidden. Painting a dialog into a page nobody is looking at is not a capture, it is a dialog waiting to ambush them when they come back.
And Google's guidance on intrusive interstitials targets exactly the pattern people build here: content covered by a popup on a mobile page reached from search, especially immediately on arrival. A standalone dialog that must be dismissed before the content can be read is the named example. It is not a penalty you can measure on one page, and it is not a reason to have no email capture. It is a reason for the mobile version to be a different component.
| Device | Signal that works | What to render | What not to do |
|---|---|---|---|
| Desktop, fine pointer | mouseout through the top edge | a modal dialog, once | fire on the first second of the visit |
| Desktop, returning visitor who dismissed | none | nothing | a second modal with a smaller offer |
| Mobile | scroll depth reached, then a pause | an inline block, or a bottom sheet that does not cover the content | a full-screen modal on arrival |
| Mobile, long read with no scrolling | idle while visible | the same inline block | anything on pagehide |
| Any device, cart or checkout | none | nothing at all | an offer that interrupts a purchase in progress |
The bottom row is the one that costs real money. A buyer on the checkout page has already decided. Interrupting them with a discount they now have to go and find a way to apply is a conversion mechanic pointed backwards, and it also invites them to leave and hunt for a better code. Exclude the cart, the checkout and the confirmation page by path, not by remembering to.

The hook, with the guards that matter
import { useEffect, useRef, useState } from 'react'
type Options = {
/** paths where this never fires, matched by prefix */
jamais?: string[]
/** minimum seconds on the page before the trigger arms */
delaiArmement?: number
/** share of the page scrolled before the mobile trigger arms */
profondeur?: number
/** seconds of stillness that count as the mobile exit signal */
inactivite?: number
}
const CLE = 'ei:vu'
export function useExitIntent({
jamais = ['/cart', '/checkout', '/order'],
delaiArmement = 8,
profondeur = 0.6,
inactivite = 25,
}: Options = {}) {
const [ouvert, setOuvert] = useState(false)
const arme = useRef(false)
useEffect(() => {
if (jamais.some((p) => location.pathname.startsWith(p))) return
if (dejaVu()) return
const grossier = matchMedia('(pointer: coarse)').matches
const tirer = () => {
if (!arme.current) return
arme.current = false
marquerVu()
setOuvert(true)
}
// The page gets a few seconds before anything can fire. A dialog on the
// first second is not exit intent, it is an entrance toll.
const armement = setTimeout(() => { arme.current = true }, delaiArmement * 1000)
if (!grossier) {
const surSortie = (e: MouseEvent) => {
if (e.relatedTarget || e.clientY > 0) return
tirer()
}
document.addEventListener('mouseout', surSortie)
return () => { clearTimeout(armement); document.removeEventListener('mouseout', surSortie) }
}
// Coarse pointer: no exit signal exists. Read engagement instead.
let repos: number | undefined
const replanifier = () => {
clearTimeout(repos)
repos = window.setTimeout(tirer, inactivite * 1000)
}
const surDefilement = () => {
const vu = (scrollY + innerHeight) / document.documentElement.scrollHeight
if (vu >= profondeur) tirer()
else replanifier()
}
addEventListener('scroll', surDefilement, { passive: true })
addEventListener('touchstart', replanifier, { passive: true })
replanifier()
return () => {
clearTimeout(armement)
clearTimeout(repos)
removeEventListener('scroll', surDefilement)
removeEventListener('touchstart', replanifier)
}
}, [delaiArmement, inactivite, profondeur, jamais])
return { ouvert, fermer: () => setOuvert(false) }
}
/* Two memories, deliberately. The session one stops a second appearance in
this tab. The durable one stops it for weeks after a dismissal, and forever
after a submission. */
function dejaVu(): boolean {
try {
if (sessionStorage.getItem(CLE)) return true
const jusqua = Number(localStorage.getItem(CLE) ?? 0)
return Date.now() < jusqua
} catch {
return true // storage blocked: assume seen, and show nothing
}
}
function marquerVu(jours = 30) {
try {
sessionStorage.setItem(CLE, '1')
localStorage.setItem(CLE, String(Date.now() + jours * 864e5))
} catch {}
}
Four decisions in there are the ones that separate this from the snippet everybody pastes.
pointer: coarsesplits the two behaviours, not the user agent string. A touchscreen laptop reports both, which is fine: it gets the desktop branch and the mobile branch never arms.- The arming delay. A dialog that can fire in the first second turns a fast bounce into an interrupted one. Eight seconds is a starting value, not a law.
- Storage failures fail closed. In a private window, or with site data blocked,
dejaVureturnstrueand nothing is shown. Showing a modal you cannot remember dismissing means showing it on every page view, which is the worst version of this component that exists. - It fires once, ever, per arming.
arme.current = falsebefore the state update, so a fast pointer that leaves twice does not queue two dialogs.
How do you build the dialog without writing a focus trap?
You do not write one. The platform has it.
<dialog> opened with showModal() gives you the focus trap, the Escape key, the top layer above every z-index on the page, inertness of the content behind, and a ::backdrop pseudo-element to style. Hand-rolled traps are where accessibility bugs live, and there is no reason to keep one in a repository.
export function ExitDialog({ ouvert, fermer }: { ouvert: boolean; fermer: () => void }) {
const ref = useRef<HTMLDialogElement>(null)
const invoquant = useRef<Element | null>(null)
useEffect(() => {
const d = ref.current
if (!d) return
if (ouvert && !d.open) {
invoquant.current = document.activeElement
d.showModal() // focus trap, Escape and top layer, free
} else if (!ouvert && d.open) {
d.close()
}
}, [ouvert])
return (
<dialog
ref={ref}
aria-labelledby="ei-titre"
onClose={() => {
fermer()
// Belt as well as braces: put focus back where it came from.
;(invoquant.current as HTMLElement | null)?.focus?.()
}}
onClick={(e) => { if (e.target === ref.current) ref.current?.close() }}
>
<form method="dialog" className="ei-fermer">
<button aria-label="Close">×</button>
</form>
<h2 id="ei-titre">Ten percent off your first order</h2>
<p>Use <strong>FIRST10</strong> at checkout. One code, no expiry games.</p>
<form onSubmit={onSubmit}>
<label htmlFor="ei-mail">Email</label>
<input id="ei-mail" name="email" type="email"
autoComplete="email" inputMode="email" required />
<button type="submit">Send me the code</button>
</form>
<p className="ei-note">
We email the code and, at most, one message a month. Unsubscribe in one click.
</p>
</dialog>
)
}
The onClick line handles the backdrop: a click whose target is the dialog element itself landed on the backdrop rather than on the content, which is the cheapest correct dismissal. The inner <form method="dialog"> closes without JavaScript at all.
Two details that are not in the markup. Respect prefers-reduced-motion and drop the entrance animation rather than shortening it. And do not autofocus the email input: showModal() already moves focus into the dialog, and jumping straight to a text field raises the keyboard on the devices where this component is least welcome anyway.
What should the offer actually be?
A code you honour, or nothing.
If the modal says ten percent, the code has to work at checkout, on the items the buyer is looking at, with no minimum you did not mention. A code that is refused at the payment step converts a captured email into a support ticket and a shopper who now believes the discount was bait. That failure is worse than never having asked, because it happens after they trusted you once.
So the honest checklist before shipping the component:
- The code exists in your payment provider and is active.
- Its restrictions are the ones printed in the dialog, and they are printed.
- There is a discount code field on the checkout page, visible and labelled.
- Applying it updates a total the buyer can see before they press pay.
If any of those four is not true, ship the dialog with no discount and a plain reason to subscribe (restocks, a first look, one email a month), or ship nothing this week. And if you would rather move the same margin without a code at all, the mechanic that does it is a free shipping threshold bar, which raises the order instead of discounting it, and a tier selector on the product page.
The discount field itself is part of the checkout screen, and it has its own trap: an open, prominent Promo code box sends buyers who have no code off to look for one. Where it goes and how it should behave is in checkout page design, field by field.
More broadly, an email capture is a way to talk to traffic you already have, and the rest of that argument, for a store with no budget for more of it, is in conversion rate optimisation without traffic.
Disclosure. We build uxgen, an MCP server that gives a coding agent commerce components as HTML that stays in the merchant's repository. The dialog above is complete as written; nothing here needs it.
FAQ
How do you detect exit intent in JavaScript?
Listen for mouseout on the document and act only when relatedTarget is null and clientY is at or below zero, which means the pointer left the window through the top edge where the browser controls are. Guard it with a delay so it cannot fire in the first seconds of a visit, and with a check that the pointer is not coarse.
Does exit intent work on mobile?
No. There is no pointer that can leave through the top of the window, and the events that fire when a phone user leaves happen once the page is already hidden. Use scroll depth followed by a pause, or a period of inactivity while the tab is visible, and render an inline block or a bottom sheet rather than a modal.
Will an exit-intent popup hurt SEO?
The risk is not the technique but the shape. Google's intrusive interstitial guidance describes content covered by a popup on a mobile page arrived at from search, particularly immediately on arrival. A desktop dialog fired on exit is not that; a full-screen mobile modal that must be dismissed before the content can be read is exactly that.
How often should an exit-intent modal appear?
Once per session at most, and then suppressed for weeks after a dismissal and permanently after a submission. Keep both memories, a session one and a dated one in local storage, and treat a storage failure as already seen, because a modal you cannot remember showing is a modal that appears on every page view.