Exit intent popup for user retention

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.
Development and maintenance of all types of websites:
Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:
Development stages
Latest works
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    822
  • image_crm_chasseurs_493_0.webp
    CRM development for Chasseurs
    847
  • image_website-sbh_0.png
    Website development for SBH Partners
    999
  • image_website-_0.png
    Website development for Red Pear
    451

Implementing Exit Intent Popup for User Retention

Exit Intent is a pattern where a popup appears at the moment a user is about to leave a page. On desktop, this is detected by mouse movement toward the top of the viewport (toward the browser's close button or address bar). On mobile devices, it is triggered by pressing the back button or by the visibilitychange event.

Desktop Detection

The key point: respond not to any upward movement, but to rapid movement toward the top of the screen with a small Y-coordinate.

// exit-intent.ts
interface ExitIntentOptions {
  threshold?: number;       // px from top, default 20
  delay?: number;           // minimum seconds on page before showing
  cooldown?: number;        // ms until next trigger (0 = show once)
  onExit: () => void;
}

export function createExitIntentDetector(options: ExitIntentOptions) {
  const {
    threshold = 20,
    delay = 3,
    cooldown = 0,
    onExit,
  } = options;

  let triggered = false;
  let pageEnteredAt = Date.now();
  let lastTriggeredAt = 0;

  const STORAGE_KEY = 'exit_intent_last_shown';

  // Check if we should show based on session/cooldown period
  function shouldShow(): boolean {
    if (triggered && cooldown === 0) return false;
    if (Date.now() - pageEnteredAt < delay * 1000) return false;

    if (cooldown > 0) {
      const stored = sessionStorage.getItem(STORAGE_KEY);
      if (stored && Date.now() - Number(stored) < cooldown) return false;
    }

    return true;
  }

  function handleMouseLeave(e: MouseEvent) {
    // Moving toward top edge
    if (e.clientY > threshold) return;
    // Fast enough (movement speed — position derivative)
    if (!shouldShow()) return;

    triggered = true;
    lastTriggeredAt = Date.now();
    if (cooldown > 0) {
      sessionStorage.setItem(STORAGE_KEY, String(lastTriggeredAt));
    }

    onExit();
  }

  // Mobile fallback: visibilitychange
  function handleVisibilityChange() {
    if (document.visibilityState === 'hidden' && shouldShow()) {
      triggered = true;
      onExit();
    }
  }

  document.addEventListener('mouseleave', handleMouseLeave);
  document.addEventListener('visibilitychange', handleVisibilityChange);

  return {
    reset() {
      triggered = false;
      sessionStorage.removeItem(STORAGE_KEY);
    },
    destroy() {
      document.removeEventListener('mouseleave', handleMouseLeave);
      document.removeEventListener('visibilitychange', handleVisibilityChange);
    },
  };
}

Popup Component (React)

// ExitIntentPopup.tsx
import { useEffect, useRef, useState } from 'react';
import { createExitIntentDetector } from './exit-intent';

interface ExitIntentPopupProps {
  headline: string;
  subtext: string;
  ctaLabel: string;
  onCta: () => void;
  offer?: string;             // e.g., "10% discount with code EXIT10"
  delaySeconds?: number;
}

export function ExitIntentPopup({
  headline,
  subtext,
  ctaLabel,
  onCta,
  offer,
  delaySeconds = 5,
}: ExitIntentPopupProps) {
  const [visible, setVisible] = useState(false);
  const [email, setEmail] = useState('');
  const dialogRef = useRef<HTMLDialogElement>(null);
  const detectorRef = useRef<ReturnType<typeof createExitIntentDetector>>();

  useEffect(() => {
    detectorRef.current = createExitIntentDetector({
      delay: delaySeconds,
      cooldown: 24 * 60 * 60 * 1000, // show once per day
      onExit: () => setVisible(true),
    });

    return () => detectorRef.current?.destroy();
  }, [delaySeconds]);

  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;

    if (visible) {
      dialog.showModal();
      // focus on first interactive element
      dialog.querySelector<HTMLElement>('input, button')?.focus();
    } else {
      dialog.close();
    }
  }, [visible]);

  function handleClose() {
    setVisible(false);
  }

  function handleCta() {
    onCta();
    setVisible(false);
  }

  function handleBackdropClick(e: React.MouseEvent<HTMLDialogElement>) {
    if (e.target === dialogRef.current) handleClose();
  }

  return (
    <dialog
      ref={dialogRef}
      onClick={handleBackdropClick}
      className="rounded-2xl p-0 max-w-md w-full shadow-2xl backdrop:bg-black/50"
    >
      <div className="p-8">
        {/* Close button */}
        <button
          onClick={handleClose}
          aria-label="Close"
          className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 text-xl leading-none"
        >
          ×
        </button>

        <h2 className="text-xl font-bold text-gray-900 mb-2">{headline}</h2>
        <p className="text-gray-600 text-sm mb-4">{subtext}</p>

        {offer && (
          <div className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 mb-4">
            <p className="text-sm font-medium text-amber-800">{offer}</p>
          </div>
        )}

        <div className="flex gap-2">
          <input
            type="email"
            value={email}
            onChange={e => setEmail(e.target.value)}
            placeholder="[email protected]"
            className="flex-1 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
          />
          <button
            onClick={handleCta}
            className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700"
          >
            {ctaLabel}
          </button>
        </div>

        <button
          onClick={handleClose}
          className="mt-3 text-xs text-gray-400 hover:text-gray-600 w-full text-center"
        >
          No, I'm leaving
        </button>
      </div>
    </dialog>
  );
}

Analytics for Impressions and Conversions

Without measurement, popup optimization is impossible. Minimal event set:

function trackPopupEvent(
  event: 'shown' | 'closed' | 'converted',
  meta?: Record<string, unknown>
) {
  // Google Analytics 4
  if (typeof gtag !== 'undefined') {
    gtag('event', `exit_intent_${event}`, {
      page_path: window.location.pathname,
      ...meta,
    });
  }

  // Custom analytics
  fetch('/api/analytics/events', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    keepalive: true, // important for events on page close
    body: JSON.stringify({
      event: `exit_intent.${event}`,
      url: window.location.href,
      timestamp: new Date().toISOString(),
      ...meta,
    }),
  });
}

keepalive: true is critical for fetch in events that may fire when closing a tab — without it, the browser may cancel the request.

Common Implementation Mistakes

Several typical issues:

Popup on every visit — annoying, conversion drops. Need cooldown of at least one day via localStorage/cookie.

Showing on mobile via mouseleave — event doesn't fire. Need separate detector via visibilitychange or pagehide.

Missing <dialog> polyfill — HTMLDialogElement is supported in all modern browsers (Chrome 37+, Firefox 98+, Safari 15.4+), but for older support, use dialog-polyfill or custom implementation via aria-modal.

Timeline

Basic popup with detector and analytics — one to two days. A/B testing multiple variants with automatic winner selection — another two to three days.