ui-039

Exit Intent Popup

£10 or the whole Vault, £79.20

An exit intent popup for promo codes and email capture: exit, scroll, delay or manual triggers, frequency capping and an accessible modal.

intermediate
7 more details
dialog-animationfocus-managementmask-revealstaggerform-animationstate-transitionkeyboard-navigation

This element reads better at your own screen size than in the frame below:

Updated

About this component

An exit intent popup with the trigger rules a real promo or newsletter popup needs. It opens when the pointer leaves through the top of the viewport, when the reader scrolls past a chosen depth, after a time delay, or when your own code calls it, and each rule is a data attribute on the component root. It remembers that it has been shown, once per session or once per N days, so nobody sees the same discount popup twice in a visit.

Read the full effect overview

It is a proper modal, not a floating div: role=dialog with aria-modal, focus moves to the email field on open, is trapped while the dialog is open and is handed back when it closes, Escape and the backdrop dismiss it, and the page behind is scroll-locked with the scrollbar gap compensated so nothing shifts. The dialog is hidden for real when closed, so an invisible layer never covers the page.

The entrance is the part a buyer would not hand-roll. The backdrop fades in while its blur ramps up from zero, the offer panel and the content panel slide in from opposite sides and meet in the middle, the headline words rise through a mask one after another, the body and the email field follow, and the button lands last with a small scale-in. Submitting hands the email out as an event for your provider and morphs the button into a "Code sent" state before the dialog closes itself. On a phone the card becomes a bottom sheet and rises instead of splitting. Two themes, light and dark, defined entirely in CSS custom properties: dark is the same design re-valued, not a second skin. The demo shows it over a short document, which is all the component needs to prove its scroll lock.

Exit Intent Popup - UI component preview

What's included

10 items
  • Four trigger rules on one attribute: exit intent, scroll depth, time delay and manual
  • Exit intent fires when the pointer leaves through the top edge, with a cooldown after load
  • Shown once per session, once per N days, or every time, stored under a key you choose
  • Touch fallback: scroll and delay still work, plus an optional fast-scroll-up rule for phones
  • Accessible modal: role=dialog, aria-modal, focus trap, focus return, Escape and backdrop close
  • Body scroll lock with scrollbar-gap compensation, and the dialog is hidden for real when closed
  • Split entrance: backdrop blur ramp, panels meeting from opposite sides, headline words rising through a mask
  • Submit morphs the button into a sent state and dispatches the email as a popup:submit event
  • Bottom-sheet layout under 640px with full-width controls and a rise instead of the split
  • GSAP core only, and two themes: light and dark, one design at two token values

Perfect for

5 use cases
  • Ecommerce discount popup that catches a visitor about to leave the cart
  • Newsletter popup on a blog, shown after a scroll depth that proves they were reading
  • Lead magnet or email capture modal on a landing page, shown once per week per visitor
  • Promo code announcement for a sale window, opened by your own button or script
  • Abandonment offer on a pricing page, triggered by exit intent on desktop and delay on mobile

How it works

3 sections

Trigger rules on the root

data-trigger takes a space-separated list of exit, scroll, delay and manual. Exit intent is a mouseleave on the document element whose clientY is at or above zero, listened for on fine pointers only and ignored during a short cooldown after load. Scroll depth reads scrollY against the page's scrollable height. The delay is a setTimeout from load. All of them go through one gate that checks the popup is armed, not already open, not already shown on this page load, and not already shown for the current session or window, which is read from sessionStorage or a timestamp in localStorage depending on data-once. The once-per-load rule covers exit intent too, so the popup never reappears the second time the pointer heads for the tab bar; only the manual open() bypasses it.

The split entrance

The backdrop fades in while its blur radius is carried from zero to the variant's --backdrop-blur and written on every update. The two panels start offset to either side and fully transparent, set by GSAP rather than CSS so no transform is left behind, and travel in inside a card with overflow hidden. The headline is split into word spans at init, each in an overflow-hidden mask, and they rise on a 0.04s stagger; the body and field fade up after them and the button scales in. Closing is a shorter timeline: content fades, the panels part by 6%, the card drops 24px and fades, and the backdrop blur unwinds. The root is hidden on completion so the closed popup never covers the page.

Focus, scroll lock and the phone

The previously focused element is remembered on open, the email field is focused with preventScroll, and one document-level keydown handler traps Tab within the dialog and closes on Escape. The page behind is locked on both <html> and <body> (body's overflow only reaches the viewport while html's is visible), with the scrollbar's width added as padding and the scroll offset restored on close, so the reader lands back exactly where they opened it. Under 640px the entrance switches at open time to a rise from the bottom for the sheet layout, and the CSS collapses the card to a single column with the offer panel as a strip. Submitting prevents the form's default, dispatches popup:submit with the email, tweens the button's background to the success colour while the label crossfades, and closes the dialog 1.6 seconds later.

Difficulty Intermediate
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this UI element's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

Paid UI element

Purchase to unlock the code.

Buying Exit Intent Popup opens the HTML, CSS and JavaScript source, the full documentation, an AI setup prompt for your editor, and every update we ship to it. Standard license: unlimited personal and commercial projects. Every paid product is tested for desktop and mobile layouts, reduced-motion handling, animation cleanup and keyboard controls wherever there is something to operate.

£10 Standard license, unlimited projects

Browse UI elements

Or the whole library: the Vault, £79.20, one payment.

Documentation

Quick Start

1. Add to your HTML <head>:

Code snippet omitted: it ships with the download.

2. Add before the closing </body> tag:

Code snippet omitted: it ships with the download.

3. Add the component markup (the block between <!-- Component starts here --> and <!-- Component ends here --> in index.html) anywhere in <body>. Its root looks like this:

Code snippet omitted: it ships with the download.

data-exit-popup is what the script looks for. Keep the hidden attribute on the root: that is the closed state.

4. Listen for the email:

Code snippet omitted: it ships with the download.

The form never posts anywhere by itself; popup:submit is the hand-off to your provider.

What's Included

  • index.html: the demo page, the component markup and the theme toggle
  • assets/style.css: the component and both complete themes as custom properties
  • assets/script.js: readable, commented GSAP core source with the public API, the triggers and cleanup
  • assets/script.min.js: minified production script
  • thumbnail.html: build-only capture state

Using It With Your Own Design

What the component needs from your markup: the root carrying data-exit-popup, and inside it these hooks, which are the script's whole contract:

Hook What it is
data-backdrop The dimming layer; click closes
data-dialog The card, with role="dialog" aria-modal="true" aria-labelledby aria-describedby
data-panel-media The left (image) panel, aria-hidden because it is decorative
data-panel-content The right panel; carries data-lenis-prevent because it can scroll vertically on a short viewport
data-headline The heading; the script splits it into word spans at init
data-reveal Anything that should fade up after the headline (the body, the field, the dismiss link)
data-form The form; its input[type="email"] receives focus on open, its [type="submit"] is the button that morphs
data-submit-label / data-submit-done The two labels inside the submit button that crossfade
data-close / data-dismiss Anything that closes the popup (the × and the "No thanks" link)

The offer panel is pure CSS: a flat accent wash (--media-bg), a hairline against the content panel (--media-line), the figure and its two lines of supporting copy (--media-ink). Replace its contents with an <img> if you have one; the script only moves the panel.

What is only the demo's furniture: the short document behind the popup (the .stage-doc rules and the <article> that uses them, there so the popup has a page to open over and the scroll lock has something to lock), the 44px toolbar strip across the top with the "Open popup" button and the theme toggle, and the Google Font links. Delete all of it; none of it is part of what you bought, and nothing in assets/script.js reads a .stage-* class. The demo root uses data-trigger="exit scroll delay manual" data-delay="6000" data-scroll="60" data-once="never" so every rule is reachable and the toolbar's button can re-show it; your page wants the defaults.

CSS the component depends on: never put a CSS transform on [data-dialog], the two panels, the headline words or the submit button. GSAP owns those transforms and reads a CSS translate as a pixel offset it never returns from. The card's overflow: hidden is what keeps the panels crisp as they travel. The .has-js pre-hides at the bottom of assets/style.css stop a frame of the finished card flashing before the entrance starts; leave them, they restore themselves under prefers-reduced-motion. The sheet breakpoint is 640px in both the CSS and CONFIG.sheetBreakpoint in the script; change both together.

Triggers

All on the root element.

Attribute Values Default What it does
data-trigger space-separated list of exit, scroll, delay, manual exit delay Which rules may open the popup
data-delay milliseconds 15000 Time after load for the delay rule
data-scroll percentage of the page's scrollable height 50 Depth for the scroll rule
data-once session, days:N, never session How long to remember that it was shown
data-storage-key any string exit-popup The sessionStorage / localStorage key
data-cooldown-ms milliseconds 800 Exit intent is ignored this long after load
data-touch-back true off Touch fallback: opens on a fast scroll upward after a deep read

Exit intent is a mouseleave on document.documentElement with clientY <= 0, so it fires when the pointer heads for the tab bar or the address bar, not when it leaves sideways. It is listened for on fine pointers only.

Every automatic rule shows the popup at most once per page load, exit intent included, and nothing turns that off. A popup that reappears each time the pointer flicks to the top of the window, or each time the reader crosses the scroll depth again, reads as broken rather than persistent. data-once is about later visits, not this one. exitPopup.open() is unaffected and always opens, which is how the demo's button re-shows it.

On a phone there is no exit intent. The scroll and delay rules still work, and data-touch-back="true" adds a velocity rule: a scroll upward faster than 1.5px/ms, after the reader has been past 30% of the page, opens the popup. It is off by default because it is a heuristic; turn it on for pages long enough that a fast flick up means "back to the top and away".

manual lists nothing extra; it is there so a root with only your own trigger reads honestly. Any element carrying data-popup-open opens the popup on click, and so does window.exitPopup.open().

Keyboard & Accessibility

Key What it does
Escape Closes; focus returns to whatever had it before the popup opened
Tab / Shift+Tab Cycles within the dialog while it is open and wraps at either end
Enter in the field Submits the form
  • Roles: role="dialog" with aria-modal="true", aria-labelledby pointing at the headline and aria-describedby at the body. The offer panel is aria-hidden. The email field has a real <label> (visually hidden in the demo).
  • On open, focus moves to the email field with preventScroll, so the locked page never jumps to reach it. On close it goes back to the previously focused element.
  • The backdrop click and both close controls dismiss the popup; the root is hidden when closed, so nothing invisible covers the page.
  • Body scroll is locked while open, with the scrollbar's width added as right padding so the layout does not shift.
  • After submit the button is aria-disabled rather than disabled, so focus stays inside the trap while the sent state shows.
  • Under prefers-reduced-motion: reduce the dialog appears in its final state with a zero-duration fade and no blur ramp; every control still works.
  • On coarse pointers the close button and the dismiss link grow to 44px targets.

How It Works

One gate for every trigger. fire(source) checks that the popup is armed, not already open and not already shown for the current session or window (sessionStorage for session, a timestamp in localStorage for days:N), then calls open(). Storage reads are wrapped so a private window that throws simply shows the popup.

The split entrance. open() un-hides the root, locks scroll and sets every start state with gsap.set (panels at xPercent -18 and +18 with opacity 0, headline words at yPercent 110 inside their masks, the button at scale 0.92). A timeline then fades the backdrop while a plain object tweens its blur radius from 0 to --backdrop-blur, writing backdrop-filter on every update; the panels meet on power4.out; the words rise with a 0.04s stagger; the body and field fade up; the button lands last. Below 640px the dialog rises as a sheet on yPercent instead of splitting.

Closing. A shorter timeline on power2.in fades the content, parts the panels by 6%, drops the card 24px while fading it and unwinds the blur. hidden goes back on the root in onComplete, and the transforms are reset to zero so the next open starts clean. Focus is handed back before the tween starts.

Submit. The form's default is prevented, popup:submit is dispatched with detail.email, the button's background tweens to --success while its two labels crossfade, and a 1.6s timer closes the dialog with reason submit.

Customisation

  • --backdrop-blur on the theme block is the blur the ramp ends on; set it to 0px for no blur.
  • Colours, radii, the shadow and the offer panel's fill are custom properties on each body[data-variant] block in assets/style.css; the table under Themes lists them.
  • On phones --media-strip (default 108px) is the height of the offer strip; 0px removes it, and the strip drops the supporting note so the figure and caption sit on one line.
  • Timings and eases are the gsap.to calls inside open() and close() in assets/script.js; the auto-close after submit is the 1600 in the submit handler.
  • The touch-back thresholds (1.5px/ms, 30% depth) are the two numbers in the scroll handler.

Themes

Two themes, one design: light (the default) and dark, the same tokens re-valued. Set the attribute on <body> in the demo, or on whatever element owns the popup in your own page:

Code snippet omitted: it ships with the download.

Leaving the attribute off gives you light. The demo's toggle and the ?variant=dark URL parameter only set this attribute; neither is part of the component, and nothing in assets/script.js reads the theme name.

Every colour, radius, blur and shadow is a custom property on the two body[data-variant] blocks at the top of assets/style.css. To re-value them for your own brand, this is the whole list:

Token What it paints
--ground, --ground-2, --raised, --hover The page behind, the toolbar strip, the card, and hover states
--ink, --ink-2, --ink-3 Headline, body copy, labels and meta
--line, --line-strong Hairlines and the input border
--accent, --accent-ink, --accent-wash The submit button, its label, and the focus ring
--success, --success-ink The button's sent state (read by the script with getComputedStyle)
--backdrop, --backdrop-blur The dimming layer and the blur the ramp ends on
--field-bg, --field-border The email field
--media-bg, --media-ink, --media-ink-soft, --media-ink-softer, --media-line The offer panel
--display-size, --display-weight, --display-tracking, --display-leading The headline
--font, --radius, --radius-sm The face and the radii

Both themes use one face, Inter, loaded in the demo from Google Fonts; point --font at whatever your page already loads and drop the link.

Add a third: copy either body[data-variant="..."] block, rename it and change the values. Set color-scheme to match the ground so native controls follow it. Nothing in assets/script.js changes, and no tween hard-codes a colour.

Requirements

  • GSAP 3.12+ (the demo uses 3.15.0; core only, no plugins)
  • No build step, no framework

Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.