Innovations

Exit Intent Popup

Popup triggered when the user\

Preview

Source

tsx
"use client";

import { useState, useEffect, useCallback } from "react";
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ArrowRight, Gift } from "lucide-react";

const SESSION_KEY = "innovations_exit_intent_shown";

export default function ExitIntent() {
  const [open, setOpen] = useState(false);
  const [email, setEmail] = useState("");
  const [submitted, setSubmitted] = useState(false);
  const [firedOnce, setFiredOnce] = useState(false);

  const fire = useCallback(() => {
    if (firedOnce) return;
    setFiredOnce(true);
    setOpen(true);
    sessionStorage.setItem(SESSION_KEY, "true");
  }, [firedOnce]);

  // SSR-guarded exit intent listener
  useEffect(() => {
    if (sessionStorage.getItem(SESSION_KEY)) return;

    function onMouseLeave(e: MouseEvent) {
      if (e.clientY <= 0) {
        fire();
      }
    }

    document.addEventListener("mouseleave", onMouseLeave);
    return () => document.removeEventListener("mouseleave", onMouseLeave);
  }, [fire]);

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!email) return;
    setSubmitted(true);
  }

  return (
    <div className="flex min-h-[200px] flex-col items-center justify-center gap-4 p-8">
      {/* Demo trigger */}
      <Button
        variant="outline"
        onClick={fire}
      >
        Trigger exit intent (demo)
      </Button>
      <p className="text-xs text-muted-foreground text-center max-w-xs">
        In production, this fires automatically when the cursor leaves the top of the viewport.
        Uses sessionStorage so it shows at most once per session.
      </p>

      <Dialog open={open} onOpenChange={setOpen}>
        <DialogContent className="sm:max-w-md overflow-hidden p-0">
          {/* Gradient header */}
          <div className="bg-gradient-to-br from-violet-600 to-purple-700 p-8 text-white text-center">
            <div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm">
              <Gift className="h-7 w-7 text-white" />
            </div>
            <p className="text-xs font-semibold uppercase tracking-widest text-white/70 mb-1">
              Wait — before you go
            </p>
            <h2 className="text-2xl font-extrabold leading-tight">
              Grab this free before you leave
            </h2>
          </div>

          <div className="p-6 sm:p-8">
            {submitted ? (
              <div className="py-4 text-center space-y-3">
                <div className="text-4xl">🎉</div>
                <h3 className="font-bold text-lg text-foreground">It's in your inbox!</h3>
                <p className="text-sm text-muted-foreground">
                  We sent the guide to <strong>{email}</strong>. Check your spam folder if you
                  don't see it within 5 minutes.
                </p>
                <Button onClick={() => setOpen(false)} className="mt-2">
                  Close
                </Button>
              </div>
            ) : (
              <>
                <DialogHeader className="text-left mb-5">
                  <DialogTitle className="text-lg font-bold">The Growth Playbook</DialogTitle>
                  <DialogDescription className="text-base text-muted-foreground">
                    The exact framework we use to help clients 3x revenue in under 90 days.
                    Enter your email and we'll send it instantly.
                  </DialogDescription>
                </DialogHeader>

                <form onSubmit={handleSubmit} className="space-y-3">
                  <Input
                    type="email"
                    placeholder="[email protected]"
                    value={email}
                    onChange={(e) => setEmail(e.target.value)}
                    required
                    className="h-11"
                  />
                  <Button type="submit" className="w-full h-11 gap-2 font-semibold">
                    Send me the free guide
                    <ArrowRight className="h-4 w-4" />
                  </Button>
                </form>

                <button
                  onClick={() => setOpen(false)}
                  className="mt-4 block w-full text-center text-xs text-muted-foreground hover:text-foreground transition-colors"
                >
                  No thanks, I'll pass on the free guide
                </button>
              </>
            )}
          </div>
        </DialogContent>
      </Dialog>
    </div>
  );
}
Claude Code Instructions

CLI Install

npx innovations add exit-intent

Where to use it

Add to your root layout so exit intent detection is active on every page. In Next.js root layout (app/layout.tsx): import ExitIntent from '@/components/innovations/freebies/exit-intent'; In Astro root layout: <ExitIntent client:load /> The mouseleave listener and sessionStorage check are inside useEffect — fully SSR-safe. The popup fires at most once per browser session (sessionStorage key). Remove the demo trigger button before shipping to production. Wire the form to your email provider. Consider A/B testing different offers.