DEV Community

A0mineTV
A0mineTV

Posted on

Building a Smart Exit Confirmation Modal with the Popstate API in TypeScript

When users try to leave a web application by pressing the browser's back button, you might want to show them a confirmation dialog to prevent accidental navigation. While window.onbeforeunload can handle page refresh or tab closing, it doesn't work for browser back/forward navigation. That's where the Popstate API comes in handy.

In this article, I'll show you how to build a sophisticated exit confirmation system using the Popstate API with TypeScript and Vite.

What is the Popstate API?

The popstate event is fired when the user navigates through their session history (like clicking the back/forward buttons). Unlike beforeunload, it gives us full control over the user experience and allows us to show custom modals instead of browser-native dialogs.

Understanding Browser Navigation Events

To fully grasp why popstate is so powerful, let's compare it with other navigation events:

beforeunload Event:

  • Triggers when page is about to be unloaded (refresh, close tab, navigate away)
  • Shows browser's native dialog (can't customize appearance)
  • Limited to simple text messages in modern browsers
  • Doesn't trigger for back/forward button navigation

unload Event:

  • Fires when page is actually being unloaded
  • No user interaction possible (too late to prevent)
  • Mainly for cleanup tasks

popstate Event:

  • Fires when user navigates through session history
  • Perfect for back/forward button interactions
  • Allows complete custom UI control
  • Can prevent the navigation entirely

The Browser History Stack

Every browser maintains a history stack that looks like this:

[google.com] → [github.com] → [your-app.com] → [settings-page]
                                              ↑ current position
Enter fullscreen mode Exit fullscreen mode

When a user:

  • Clicks a link: New entry is pushed to the stack
  • Presses back: Browser pops the current entry and navigates to previous
  • Presses forward: Browser moves forward in the stack
  • Uses history.pushState(): We can manipulate this stack programmatically

Why Traditional Approaches Fall Short

Problem with beforeunload:

// This WON'T work for back button!
window.addEventListener('beforeunload', (e) => {
  e.preventDefault();
  return 'Are you sure you want to leave?'; // Only works for page refresh/close
});
Enter fullscreen mode Exit fullscreen mode

Problem with URL hash tricks:

// Old approach - unreliable and affects URL
window.location.hash = '#preventBack';
window.addEventListener('hashchange', (e) => {
  // Messy, affects browser history, not user-friendly
});
Enter fullscreen mode Exit fullscreen mode

Our popstate solution advantages:

  • ✅ Works specifically for back/forward navigation
  • ✅ Completely custom UI/UX
  • ✅ No URL pollution
  • ✅ Clean, predictable behavior
  • ✅ Works across all modern browsers

Project Setup

I'm using a minimal Vite + TypeScript setup for this project:

{
  "name": "popstate_app",
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "devDependencies": {
    "typescript": "~5.8.3",
    "vite": "^7.0.4"
  }
}
Enter fullscreen mode Exit fullscreen mode

The Core Implementation

Here's the main popstate handler that creates our exit confirmation system:

export function setupPopstateHandler() {
  // Navigation state flag - prevents premature triggering during setup
  let hasNavigated = false;

  /**
   * Creates and displays the exit confirmation modal
   * Reuses existing modal if already created for performance
   */
  const showExitSection = () => {
    // Check if modal already exists to avoid recreating DOM elements
    const existingSection = document.getElementById('exit-section');
    if (existingSection) {
      existingSection.style.display = 'block';
      return; // Early return - no need to recreate
    }

    // Create the modal structure
    const section = document.createElement('section');
    section.id = 'exit-section';

    // Build the modal HTML with semantic structure
    section.innerHTML = `
      <div class="exit-overlay">
        <div class="exit-modal">
          <h2>Do you really want to leave?</h2>
          <p>You are about to leave this page.</p>
          <div class="exit-buttons">
            <button id="stay-btn" class="btn-primary">Stay</button>
            <button id="leave-btn" class="btn-secondary">Leave</button>
          </div>
        </div>
      </div>
    `;

    // Apply styles for full-screen overlay with high z-index
    section.style.cssText = `
      position: fixed;        /* Fixed positioning to cover viewport */
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      z-index: 1000;          /* High z-index to appear above all content */
      display: block;
    `;

    // Add modal to DOM
    document.body.appendChild(section);

    // Get references to the action buttons
    const stayBtn = document.getElementById('stay-btn');
    const leaveBtn = document.getElementById('leave-btn');

    /**
     * "Stay" button handler
     * Hides modal and recreates history buffer for next back button press
     */
    stayBtn?.addEventListener('click', () => {
      section.style.display = 'none';
      // Critical: Re-establish the history buffer for the next back press
      history.pushState(null, '', window.location.href);
    });

    /**
     * "Leave" button handler
     * Implements multiple exit strategies with graceful fallbacks
     */
    leaveBtn?.addEventListener('click', () => {
      // STRATEGY 1: Direct window closure
      // Only works if window was opened by JavaScript
      try {
        window.close();
        // Note: If this succeeds, the code below won't execute
      } catch (e) {
        console.log('window.close() failed:', e);
      }

      // STRATEGY 2: Navigate back in history
      // Check if there's actual navigation history available
      if (window.history.length > 1) {
        window.history.back();
        return; // Exit early if this method is available
      }

      // STRATEGY 3: Force navigation using history.go()
      // Alternative method for browsers that restrict history.back()
      try {
        window.history.go(-1);
      } catch (e) {
        console.log('history.go(-1) failed:', e);
      }

      // STRATEGY 4: Redirect to blank page (delayed execution)
      // Timeout allows previous strategies to complete first
      setTimeout(() => {
        try {
          // about:blank is the cleanest exit page
          window.location.replace('about:blank');
        } catch (e) {
          // Fallback: Create a custom closing page with auto-close attempt
          window.location.replace(
            'data:text/html,<html><body><h1>Page closed</h1><script>window.close();</script></body></html>'
          );
        }
      }, 100); // 100ms delay to prevent race conditions

      // STRATEGY 5: Content replacement (final fallback)
      // Always works since it's just DOM manipulation
      setTimeout(() => {
        document.body.innerHTML = `
          <div style="
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            font-family: Arial;
          ">
            <h1>Page closed - You can close this tab</h1>
          </div>
        `;
        document.title = 'Page closed';
      }, 200); // 200ms delay ensures all other strategies have been attempted
    });
  };

  /**
   * Popstate event handler
   * Triggered when user navigates with back/forward buttons
   */
  const handlePopstate = () => {
    // Only act if we've completed initial setup
    if (hasNavigated) {
      showExitSection();
      // Immediately re-establish history buffer to prevent actual navigation
      history.pushState(null, '', window.location.href);
    }
  };

  // INITIALIZATION SEQUENCE (order is crucial!)

  // 1. Create initial history buffer entry
  //    This creates a "fake" history entry that we can intercept
  history.pushState(null, '', window.location.href);

  // 2. Mark that we're ready to handle navigation
  //    Prevents premature triggering during setup
  hasNavigated = true;

  // 3. Start listening for popstate events
  //    Now we'll catch any back/forward button presses
  window.addEventListener('popstate', handlePopstate);

  // Return cleanup function for proper memory management
  // Call this when component unmounts or page changes
  return () => {
    window.removeEventListener('popstate', handlePopstate);
    // Optional: Remove modal from DOM if it exists
    const existingSection = document.getElementById('exit-section');
    if (existingSection) {
      existingSection.remove();
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

How It Works

Understanding the core logic behind this implementation requires diving deep into browser history manipulation and the popstate event lifecycle.

1. The History Manipulation Trick

// Initial setup - this is the key to the entire mechanism
history.pushState(null, '', window.location.href);
hasNavigated = true;
Enter fullscreen mode Exit fullscreen mode

What's happening here?

When a user first loads our page, the browser history looks like this:

[Previous Page] → [Our Page]
Enter fullscreen mode Exit fullscreen mode

By calling history.pushState(), we're essentially duplicating the current page in the history stack:

[Previous Page] → [Our Page] → [Our Page (duplicate)]
Enter fullscreen mode Exit fullscreen mode

This creates a "buffer" entry. Now when the user presses the back button, instead of leaving our application entirely, they'll navigate to our duplicate entry, which triggers the popstate event while keeping them on our page.

Why null and the current URL?

  • null as state: We don't need to store any specific state data
  • Current URL: We want to stay on the same page, just create a history entry
  • The URL doesn't change visually for the user

2. The Popstate Event Lifecycle

const handlePopstate = () => {
  if (hasNavigated) {
    showExitSection();
    history.pushState(null, '', window.location.href);
  }
};
Enter fullscreen mode Exit fullscreen mode

Let's break down what happens step-by-step when the user presses back:

Step 1: User presses back button

  • Browser attempts to navigate to the previous history entry
  • This triggers the popstate event

Step 2: Our handler intercepts the navigation

  • handlePopstate() is called
  • We check if hasNavigated is true (ensuring we only act after initial setup)

Step 3: Show modal and re-establish history buffer

  • showExitSection() displays the confirmation modal
  • history.pushState() immediately creates a new buffer entry
  • The user never actually leaves the page

Visual representation:

User on page: [Previous] → [Our Page] → [Buffer]
                                        ↑ user is here

User presses back: [Previous] → [Our Page] ← [Buffer]
                                 ↑ browser tries to go here

Our handler: Show modal + [Previous] → [Our Page] → [New Buffer]
                                        ↑ user stays here
Enter fullscreen mode Exit fullscreen mode

4. Smart Exit Strategies Deep Dive

The exit logic implements a waterfall approach because browsers have different capabilities and restrictions:

leaveBtn?.addEventListener('click', () => {
  // Strategy 1: Direct window closure
  try {
    window.close();
  } catch (e) {
    console.log('window.close() failed:', e);
  }

  // Strategy 2: Navigate back if history exists
  if (window.history.length > 1) {
    window.history.back();
    return;
  }

  // Strategy 3: Force navigation
  try {
    window.history.go(-1);
  } catch (e) {
    console.log('history.go(-1) failed:', e);
  }

  // Strategy 4: Redirect to blank page
  setTimeout(() => {
    try {
      window.location.replace('about:blank');
    } catch (e) {
      window.location.replace('data:text/html,<html><body><h1>Page closed</h1><script>window.close();</script></body></html>');
    }
  }, 100);

  // Strategy 5: Clear page content
  setTimeout(() => {
    document.body.innerHTML = '<div style="..."><h1>Page closed</h1></div>';
    document.title = 'Page closed';
  }, 200);
});
Enter fullscreen mode Exit fullscreen mode

Why multiple strategies?

  1. window.close(): Only works if:

    • Window was opened by JavaScript (window.open())
    • User has allowed popup closing in browser settings
    • Not the last tab in the browser
  2. history.back() / history.go(-1): Works when:

    • There's actual navigation history
    • Browser allows programmatic navigation
    • Not restricted by security policies
  3. about:blank redirect: A universal fallback:

    • Works in all browsers
    • Provides clean exit experience
    • Shows user the page is "closed"
  4. Data URI with auto-close: Backup for restricted environments:

    • Creates a page that attempts to close itself
    • Shows clear message to user
  5. Content replacement: Final fallback:

    • Always works since it's just DOM manipulation
    • Provides clear feedback that page is "closed"
    • User can manually close the tab

Timing considerations:

  • setTimeout delays allow previous operations to complete
  • 100ms and 200ms intervals prevent race conditions
  • Gives browsers time to process each strategy before falling back

5. Modal State Management

const showExitSection = () => {
  const existingSection = document.getElementById('exit-section');
  if (existingSection) {
    existingSection.style.display = 'block';
    return;
  }
  // ... create new modal
};
Enter fullscreen mode Exit fullscreen mode

Why check for existing modal?

  1. Performance: Avoid recreating DOM elements unnecessarily
  2. Event listeners: Prevent duplicate event handlers
  3. Memory management: Reduce DOM bloat
  4. State preservation: Modal animations and styles remain consistent

The "Stay" button logic:

stayBtn?.addEventListener('click', () => {
  section.style.display = 'none';
  history.pushState(null, '', window.location.href);
});
Enter fullscreen mode Exit fullscreen mode

When user chooses to stay:

  1. Hide the modal (don't remove it for reuse)
  2. Create a new history buffer for the next potential back press
  3. User continues normally on the page

Complete Flow Diagram

Here's a visual representation of the entire process:

┌─────────────────────────────────────────────────────────────────┐
│                    INITIALIZATION PHASE                         │
└─────────────────────────────────────────────────────────────────┘
                                   │
                    ┌─────────────────────────────┐
                    │   Page loads normally       │
                    │   History: [Previous][Page] │
                    └─────────────────────────────┘
                                   │
                    ┌─────────────────────────────┐
                    │  history.pushState() called │
                    │  History: [Previous][Page]  │
                    │                    [Buffer] │
                    └─────────────────────────────┘
                                   │
                    ┌─────────────────────────────┐
                    │   hasNavigated = true       │
                    │   Event listener added      │
                    └─────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                     USER INTERACTION                            │
└─────────────────────────────────────────────────────────────────┘
                                   │
                    ┌─────────────────────────────┐
                    │    User presses BACK        │
                    │         button              │
                    └─────────────────────────────┘
                                   │
                    ┌─────────────────────────────┐
                    │   Browser attempts to       │
                    │   navigate to [Page]        │
                    │   from [Buffer]             │
                    └─────────────────────────────┘
                                   │
                    ┌─────────────────────────────┐
                    │   popstate event fires      │
                    │   handlePopstate() called   │
                    └─────────────────────────────┘
                                   │
                         ┌─────────────┐
                         │hasNavigated?│
                         └─────────────┘
                                   │
                          ┌────────┴─────────┐
                         YES              NO (ignore)
                          │                 │
                          v                 v
        ┌─────────────────────────────┐   [END]
        │   showExitSection()         │
        │   Display modal             │
        └─────────────────────────────┘
                          │
        ┌─────────────────────────────┐
        │  history.pushState()        │
        │  Create new buffer          │
        │  History: [Previous][Page]  │
        │                   [Buffer2] │
        └─────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                     USER CHOICE                                 │
└─────────────────────────────────────────────────────────────────┘
                          │
              ┌───────────┴────────────┐
              │                        │
       [STAY BUTTON]            [LEAVE BUTTON]
              │                        │
              v                        v
┌─────────────────────────┐  ┌─────────────────────────┐
│  Hide modal             │  │  Execute exit           │
│  pushState() again      │  │  strategies 1-5         │
│  Ready for next back    │  │  (waterfall approach)   │
│  [END - User stays]     │  │                         │
└─────────────────────────┘  └─────────────────────────┘
                                        │
                          ┌─────────────┴──────────────┐
                          │                            │
                   [Success Exit]               [Final Fallback]
                          │                            │
                    [Page closed]              [Content replaced]
                                                [User closes tab]

┌─────────────────────────────────────────────────────────────────┐
│                    EXIT STRATEGIES                              │
└─────────────────────────────────────────────────────────────────┘

Strategy 1: window.close()
   ├─ IF window opened by script → ✅ Close window
   └─ ELSE → Continue to Strategy 2

Strategy 2: history.back()
   ├─ IF history.length > 1 → ✅ Navigate back
   └─ ELSE → Continue to Strategy 3

Strategy 3: history.go(-1)
   ├─ IF not restricted → ✅ Navigate back
   └─ ELSE → Continue to Strategy 4

Strategy 4: about:blank redirect (100ms delay)
   ├─ IF about:blank allowed → ✅ Clean exit page
   └─ ELSE → data:text/html with auto-close

Strategy 5: Content replacement (200ms delay)
   └─ ✅ Always works → Show "Page closed" message
Enter fullscreen mode Exit fullscreen mode

This flowchart shows how the system gracefully handles different scenarios and provides multiple fallback options to ensure the best possible user experience across all browsers and contexts.

Styling the Modal

The CSS creates a professional-looking modal with overlay:

.exit-overlay {
  background-color: rgba(0, 0, 0, 0.5);
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}

.exit-modal {
  background-color: #1a1a1a;
  border-radius: 8px;
  padding: 2rem;
  max-width: 400px;
  text-align: center;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}

.exit-buttons {
  display: flex;
  gap: 1rem;
  justify-content: center;
}

.btn-primary {
  background-color: #646cff;
  color: white;
  border: none;
}

.btn-secondary {
  background-color: transparent;
  color: #888;
  border: 1px solid #888;
}
Enter fullscreen mode Exit fullscreen mode

Integration

Setting up the popstate handler in your main application is simple:

import { setupPopstateHandler } from './popstate.ts'

// Initialize the popstate handler
setupPopstateHandler()
Enter fullscreen mode Exit fullscreen mode

Key Benefits

  1. Better UX: Custom modal instead of browser dialog
  2. Cross-browser compatibility: Multiple fallback strategies
  3. TypeScript support: Full type safety
  4. Performance: Event listeners are properly cleaned up
  5. Flexibility: Easy to customize the modal content and styling

Browser Considerations

  • Safari: May restrict some navigation methods
  • Chrome/Edge: Generally supports all fallback strategies
  • Firefox: Good support with minor differences in behavior
  • Mobile browsers: Limited support for window.close()

Conclusion

The Popstate API provides a powerful way to create custom navigation experiences. This implementation shows how to build a robust exit confirmation system that handles various browser behaviors gracefully.

The key is to:

  • Manipulate browser history to intercept back button presses
  • Provide multiple fallback strategies for different browsers
  • Create a user-friendly modal interface
  • Clean up event listeners properly

You can find the complete source code for this project on GitHub and see it in action in the live demo.

Top comments (0)