Exit Intent
A leave-detector and its surface — a pointer crossing the top edge or a fast scroll-up opens a dismissable payload once per session, and never over a field somebody is typing into.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/exit-intent.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ExitIntent" module (React 19,
lucide-react for two icons, nothing else). It ships in two halves: useExitIntent,
a headless detector, and <ExitIntent>, a dismissable surface built on it. The
payload is a slot — a coupon, a save-draft nudge, a newsletter field — so the
component owns the moment and never the offer.
Contract
- useExitIntent(options?) returns { open, reason, armed, trigger, dismiss,
rearm }. reason is "pointer-out" | "scroll-up" | "manual"; trigger(reason?)
returns whether it actually opened; armed means enabled, past the quiet
period, not spent, not already open.
- options:
- open?: boolean with onOpenChange?: (open: boolean) => void — the controlled
pair; omit both and the hook owns the state.
- seen?: boolean — the persisted once-per-session flag, READ and never
written. sessionStorage, a cookie or a server flag is the consumer's call,
which is also what keeps this SSR-safe. Persist it from onTrigger. Taking it
back to false hands the internal one-shot back with it, which is how the
styled wrapper re-arms.
- enabled = true, armAfterMs = 3000 (quiet period after mount),
detect: "auto" | "pointer" | "scroll" | "both" = "auto".
- scope?: RefObject<HTMLElement | null> — the region this instance owns: its
top edge is the one that counts as leaving, and the surface covers it
instead of the viewport. Omit it for the real thing, the viewport top edge,
where the address bar and the close button live.
- scroller?: RefObject<HTMLElement | null> — which element scrolls for the
flick heuristic. Omit for the document scroller.
- topThreshold = 12, engageDepth = 240, scrollUpVelocity = 1.2 (px per ms),
holdSelector?: string.
- onTrigger?(reason), onDismiss?(via: "escape" | "overlay" | "close" |
"action"), onRefuse?(refusal: "seen" | "arming" | "editing" |
"already-open"). onRefuse is telemetry, not a state — it may fire many times.
- <ExitIntent> is forwardRef<HTMLDivElement>, takes every hook option plus
title, description, icon (null drops the badge), closeLabel, overlayClassName
and panelClassName. Leftover props spread on the root and className merges
through cn().
- children is the payload: a ReactNode, or a function handed { reason, dismiss }
so the payload's own button can close the surface without the consumer having
to take over the open state.
Behavior — the desktop detector
- One "mouseout" listener on document. Viewport mode fires when
relatedTarget === null (the pointer left the window, so the browser has no
element to report entering) AND clientY <= topThreshold. That band is the
whole point: leaving sideways or downward is the dock, the taskbar or a second
monitor, not somebody reaching for the close button.
- Scope mode instead requires event.target inside the scope, relatedTarget
outside it, clientY <= rect.top + topThreshold, and clientX within
[rect.left - topThreshold, rect.right + topThreshold]. The listener stays on
document so a scope that mounts later still works, which is exactly why the
containment test has to run before anything is measured.
Behavior — the mobile heuristic, and say out loud that it is one
- A coarse pointer has no top edge to leave, so the fallback signal is a fast
scroll-up. Listen once, on document, capture-phase and passive — a scroll event
on an inner element does not bubble, and the scroller ref is then read inside
the handler, so an element that mounts later still works and an empty ref never
silently falls back to the page. Five scalars, no sample buffer: lastY (null
until the first event seeds it), lastAt, deepest, and a running pair
(upDistance, upDuration). A scroll handler that allocates runs on every frame
of every flick.
- Per scroll event: dy = lastY - y (positive is upward),
dt = max(1, now - lastAt). dy <= 0 or dt > 250ms ends the run and zeroes it;
otherwise accumulate, but restart the run from the current sample once
upDuration passes 600ms, so a slow minute of reading upwards neither dilutes
nor rescues the burst that just happened.
- It fires when all three hold: deepest >= engageDepth (they read something),
upDistance >= 240px (a flick covers ground), and
upDistance / upDuration >= scrollUpVelocity. Then the run is zeroed again.
- State the ceiling: a programmatic scrollTo({ behavior: "smooth" }) back to the
top looks exactly like a flick. Turn enabled off while you animate, or point
scroller at an element the app does not drive itself.
Behavior — the four guards
- Already open -> refuse "already-open".
- seen, or the one-shot already spent this round -> refuse "seen".
- Still inside the quiet period -> refuse "arming".
- document.activeElement matches an editable (input except
button/submit/reset/checkbox/radio, textarea, select, contenteditable) or the
holdSelector -> refuse "editing". Use closest(), not matches(), so a whole
region can be marked and so focus inside a contenteditable counts. A refused
detection is dropped, never queued: a surface that pops the instant the caret
leaves the field is worse than one that never came.
- trigger() skips every guard but the first, because a deliberate press is not
an interruption. It is also the keyboard-and-button path — the gesture is never
the only way to reach the payload, and a reduced-motion visitor gets the same
component with the animation off.
- The one-shot is a round number rather than a boolean: attempt stamps the
current round into a ref that is read AND written in the same statement pair
inside the handler, so two detections in one tick cannot both open it, and
re-arming is just "start the next round" — nothing has to be un-written.
Rendering & styling
- Semantic tokens only: bg-background/80 with backdrop-blur-sm for the backdrop,
bg-card / text-card-foreground for the panel, border and shadow-lg for its
box, bg-primary/10 with text-primary for the icon badge,
text-muted-foreground for the description, ring for focus. No hex, no rgb, no
oklch.
- The root div is ALWAYS mounted with tabIndex={-1}: it is the focus successor
of last resort. Only the overlay mounts and unmounts with the state, so
className is for layout and panelClassName is for the skin.
- The overlay follows the scope: absolute inset-0 inside the scoped element
(which the consumer gives position: relative), fixed inset-0 otherwise.
Nothing is portalled — say so, and say the consequence: an ancestor with a
transform makes fixed position against that ancestor, so render it near the
root or pass a scope.
- Motion is decoration: animate-in fade-in-0 on the backdrop, zoom-in-95
slide-in-from-bottom-2 on the panel, both carrying motion-reduce:animate-none.
Detection, guards and every exit behave identically with animation off.
- No body scroll lock, and no clock read during render: the only timestamps come
from inside the scroll handler, so the server and the first client frame are
identical and hydration has nothing to disagree about.
Keyboard and ARIA contract
- role="dialog", aria-labelledby the title, aria-describedby the description
only when there is one. aria-modal="true" ONLY without a scope: a scoped
surface covers its own region and leaves the rest of the page clickable — this
component even ships a fallback for focus leaving it — so asserting aria-modal
there would hide a page the visitor can still use from assistive tech.
- Opening: read document.activeElement FIRST (mounting does not move focus by
itself, so it still holds the opener), then focus the panel container rather
than a button — the visitor did not ask for this, so nothing is one stray
Enter away.
- Tab and Shift+Tab cycle inside the panel: collect the focusables in DOM order,
drop anything with no client rects, wrap at both ends, and treat "focus is on
the panel itself" as the top of the ring. No tabindex arithmetic anywhere.
- Escape closes with preventDefault plus stopPropagation, so a surrounding
dialog does not close on the same press. A second, document-level Escape
listener covers only what the overlay handler cannot see — a scoped surface
whose backdrop does not cover the page, where a click outside can take focus
away — and it stands down whenever focus is inside the panel.
- Closing returns focus to whoever had it if that node is still connected, and
otherwise to the always-mounted root, so it never lands on the page body.
Unmounting while open restores focus too.
- The corner button carries aria-label={closeLabel}; both icons are aria-hidden.
Cleanup
- The mouseout listener, the passive capture-phase scroll listener (removed with
the same capture flag it was added with, or it stays), the pointer-type media
query, the quiet-period timer and the document Escape listener are every one
removed in their effect's cleanup, and each effect is keyed so a changed
option tears the old subscription down before the new one attaches.
Customization levers
- Sensitivity: topThreshold widens the exit band; engageDepth and
scrollUpVelocity move the flick between trigger-happy and only-a-real-bail-out;
armAfterMs is how long a visitor is left alone on arrival.
- Reach: detect pins the detector instead of following the pointer type — "both"
on hybrid devices, "pointer" for a desktop-only offer, "scroll" for a reader.
- Region: scope turns the whole thing into a panel-level surface (a preview
stage, an embedded editor, one dashboard pane) with no other change.
- Payload: children is a slot. A coupon with a copy button, a save-draft nudge,
a newsletter field, a two-question survey — swap it without touching the
detector, and keep it to one primary action plus one plain way out.
- Restraint: holdSelector extends the do-not-interrupt set to signature pads,
code editors and checkout steps; seen is the honest lever — persist it per
session, per user or per campaign.
- Wording and skin: title / description / icon / closeLabel cover the copy,
panelClassName and overlayClassName cover the box (a bottom sheet is
overlayClassName="items-end" plus a panel with max-w-none).
- Surface swap: keep useExitIntent and render your own banner, toast or route
change — the hook renders nothing, so the styled half is entirely optional.Concepts
- Exit intent is a guess — a pointer crossing the top edge and a fast flick upward are correlations, not intentions. Everything downstream is built for that: it fires at most once, it never blocks, and the payload is worth showing even to somebody who was not leaving at all.
- Four refusals, one shot — already open, already seen, still arming, and somebody is typing. Refusals are dropped rather than queued, because a surface that pops the instant the caret leaves the field is worse than a surface that never came; they are reported through
onRefuseso a campaign can measure how often it stayed quiet. - The flag lives outside the component — “once per session” is a promise about storage, and storage is the app’s decision. The detector reads an injected
seenboolean and writes nothing, so the same component works with sessionStorage, a cookie, or a per-user server flag — and taking that flag back to false is what re-arms it. - The gesture is never the only path —
trigger()opens the same surface from an ordinary button, skipping every guard, because a deliberate press is not an interruption. Reduced motion turns the animation off and changes nothing else. - Scope decides what leaving means — with no scope the boundary is the viewport top edge, which is where the address bar and the close button are. Point
scopeat an element and both the boundary and the surface shrink to it, which is how a panel, an embedded editor or a preview stage gets the same behaviour without owning the page. Shrinking it also dropsaria-modal, because the page around a scoped surface is still clickable and saying otherwise would hide a live page from assistive tech. - Focus is handed over, never dropped — the opener is remembered before the panel steals focus, the panel traps Tab while it is up, and closing puts focus back where it came from; when that node is gone the always-mounted root catches it, so the next Tab carries on from here instead of restarting at the top of the page.
Social Proof Toast
A rotating recent-activity notice: one entry every N seconds at most, paused by hover, focus and a hidden tab, with a mute control that reports back through a callback.
Passkey Prompt
A passkey enrolment card that asks the device what it can do before offering anything, runs the WebAuthn ceremony you inject through an AbortSignal, and treats a closed sheet as a retriable answer instead of an error.