Feedback

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Sparkles, X } from "lucide-react"
import { cn } from "@/lib/utils"

/** How the surface came to be open. */
export type ExitIntentReason = "pointer-out" | "scroll-up" | "manual"

/** A detection that was seen and deliberately not honoured. */
export type ExitIntentRefusal = "seen" | "arming" | "editing" | "already-open"

/** How the surface closed. */
export type ExitIntentDismissal = "escape" | "overlay" | "close" | "action"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/exit-intent.json

Prompt

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 onRefuse so 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 seen boolean 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 pathtrigger() 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 scope at 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 drops aria-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.

On This Page