back-dismiss

Make the mobile back button close your overlays instead of navigating away
886
Install
npm install back-dismiss
Latest Version:0.3.0
Vue:3
License:MIT
Last Updated:Oct 19, 2026
Links: GitHub  ·  npm
Maintainer: m.gamal.develop

back-dismiss

Make the mobile back button close your overlays instead of navigating away.

npm version bundle size license Live Demo

A tiny (~2KB), zero-dependency library that intercepts the mobile back button (and Escape key) to close modals, drawers, and side menus — instead of navigating away from your page.

The Problem

When a user opens a modal or side menu on mobile and presses the back button, they expect the overlay to close. Instead, the browser navigates to the previous page. 😩

The Solution

Add one line to any overlay component:

useBackDismiss(open, onClose);

Now the back button closes the overlay. On every device, every browser. ✅

Features

  • 🌍 Universal browser support — Uses CloseWatcher API where available, falls back to History API
  • ⚡ Tiny — ~2KB min+gzip, zero dependencies
  • 📱 Mobile-first — Built for Android back button, iOS gestures, and desktop Escape key
  • 🪆 Nested overlays — Automatic LIFO stack (sidebar → modal → dialog → back × 3)
  • 🛡️ Framework-safe — Preserves router state (works with Next.js, Nuxt, React Router, etc.)
  • 🔌 Works everywhere — React, Vue, Angular, Svelte, Vanilla JS, CDN
  • ♿ SSR-safe — No-op during server-side rendering

Installation

npm / yarn / pnpm

npm install back-dismiss

CDN (no build step)

<script src="https://cdn.jsdelivr.net/npm/back-dismiss/dist/back-dismiss.min.js"></script>

💡 Want to try it right now? Open the live demo on your phone.

Quick Start

React

import { useBackDismiss } from 'back-dismiss/react';

function MyModal({ open, onClose }) {
  useBackDismiss(open, onClose);

  return open ? (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal" onClick={e => e.stopPropagation()}>
        <h2>Hello!</h2>
        <button onClick={onClose}>Close</button>
      </div>
    </div>
  ) : null;
}

Vue 3

<script setup>
import { ref } from 'vue';
import { useBackDismiss } from 'back-dismiss/vue';

const open = ref(false);
useBackDismiss(open, () => { open.value = false; });
</script>

<template>
  <button @click="open = true">Open Modal</button>
  <div v-if="open" class="modal">
    <h2>Hello!</h2>
    <button @click="open = false">Close</button>
  </div>
</template>

Angular (Standalone Directive)

For Angular, create a lightweight Standalone Directive that wraps the core vanilla library. Drop this file into your project:

// back-dismiss.directive.ts
import { Directive, Input, Output, EventEmitter, OnChanges, OnDestroy, SimpleChanges } from '@angular/core';
import { BackDismiss } from 'back-dismiss';

@Directive({
  selector: '[backDismiss]',
  standalone: true
})
export class BackDismissDirective implements OnChanges, OnDestroy {
  @Input() backDismiss: boolean = false;
  @Output() dismiss = new EventEmitter<void>();

  private entry: any = null;

  ngOnChanges(changes: SimpleChanges) {
    if (changes['backDismiss']) {
      if (changes['backDismiss'].currentValue) {
        this.entry = BackDismiss.push(() => this.dismiss.emit());
      } else if (this.entry) {
        this.entry.dismiss();
        this.entry = null;
      }
    }
  }

  ngOnDestroy() {
    if (this.entry) {
      this.entry.dismiss();
      this.entry = null;
    }
  }
}

Usage in your template:

<!-- Automatically closes when the back button is pressed -->
<div *ngIf="isModalOpen" 
     [backDismiss]="isModalOpen" 
     (dismiss)="isModalOpen = false" 
     class="my-modal">
  <h2>Angular Modal</h2>
  <button (click)="isModalOpen = false">Close</button>
</div>

Svelte (Svelte 4 & 5)

<script>
  import { backDismiss } from 'back-dismiss/svelte';
  let open = false;
</script>

<button on:click={() => open = true}>Open Modal</button>

{#if open}
  <div use:backDismiss={() => open = false} class="modal">
    <h2>Hello!</h2>
    <button on:click={() => open = false}>Close</button>
  </div>
{/if}

Vanilla JavaScript

import { BackDismiss } from 'back-dismiss';

function openModal() {
  modal.style.display = 'block';
  BackDismiss.push(() => closeModal());
}

function closeModal() {
  modal.style.display = 'none';
  BackDismiss.pop();
}

CDN / Script Tag

<script src="https://cdn.jsdelivr.net/npm/back-dismiss/dist/back-dismiss.min.js"></script>
<script>
  function openModal() {
    document.getElementById('modal').style.display = 'block';
    BackDismiss.push(() => closeModal());
  }

  function closeModal() {
    document.getElementById('modal').style.display = 'none';
    BackDismiss.pop();
  }
</script>

Native HTML <dialog>

[!NOTE] While modern Chrome on Android (120+) connects showModal() to CloseWatcher natively, iOS Safari (swipe-to-go-back gesture), Firefox on Android, and non-modal dialogs (dialog.show()) will navigate the user away from the page. back-dismiss provides seamless cross-browser back-button dismissal for <dialog>.

<dialog id="myDialog">
  <h2>Native Dialog</h2>
  <p>Press mobile back or swipe to close without leaving the page.</p>
  <button id="closeBtn">Close</button>
</dialog>

<script type="module">
  import { BackDismiss } from 'back-dismiss';

  const dialog = document.getElementById('myDialog');
  let entry = null;

  function openDialog() {
    dialog.showModal();
    // Intercept back navigation (essential for iOS Safari & non-Chromium)
    entry = BackDismiss.push(() => dialog.close());
  }

  // Clean up if closed via close button, escape key, or form[method="dialog"]
  dialog.addEventListener('close', () => {
    if (entry) {
      entry.dismiss();
      entry = null;
    }
  });

  document.getElementById('closeBtn').addEventListener('click', () => dialog.close());
</script>

UI Library Examples

shadcn/ui (Sheet / Dialog / Drawer)

import { Sheet, SheetContent } from '@/components/ui/sheet';
import { useBackDismiss } from 'back-dismiss/react';

function SideMenu({ open, onOpenChange }) {
  useBackDismiss(open, () => onOpenChange(false));

  return (
    <Sheet open={open} onOpenChange={onOpenChange}>
      <SheetContent>
        <h2>Menu</h2>
      </SheetContent>
    </Sheet>
  );
}

MUI (Dialog / Drawer)

import { Dialog } from '@mui/material';
import { useBackDismiss } from 'back-dismiss/react';

function SettingsDialog({ open, onClose }) {
  useBackDismiss(open, onClose);

  return (
    <Dialog open={open} onClose={onClose}>
      <h2>Settings</h2>
    </Dialog>
  );
}

Bootstrap (React)

import { Modal } from 'react-bootstrap';
import { useBackDismiss } from 'back-dismiss/react';

function InfoModal({ show, onHide }) {
  useBackDismiss(show, onHide);

  return (
    <Modal show={show} onHide={onHide}>
      <Modal.Header closeButton>
        <Modal.Title>Info</Modal.Title>
      </Modal.Header>
      <Modal.Body>Content here</Modal.Body>
    </Modal>
  );
}

Bootstrap (Vanilla JS + CDN)

<script src="https://cdn.jsdelivr.net/npm/back-dismiss/dist/back-dismiss.min.js"></script>
<script>
  const modalEl = document.getElementById('myModal');

  modalEl.addEventListener('shown.bs.modal', () => {
    BackDismiss.push(() => bootstrap.Modal.getInstance(modalEl).hide());
  });

  modalEl.addEventListener('hidden.bs.modal', () => {
    BackDismiss.pop();
  });
</script>

Advanced Patterns

Vue 3 + v-model Pattern

<!-- AppModal.vue — reusable wrapper -->
<script setup>
import { useBackDismiss } from 'back-dismiss/vue';

const props = defineProps<{ modelValue: boolean }>();
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>();

// Works with getter function for props
useBackDismiss(
  () => props.modelValue,
  () => emit('update:modelValue', false),
);
</script>

<template>
  <div v-if="modelValue" class="modal">
    <slot />
  </div>
</template>

Usage: <AppModal v-model="showSettings">...</AppModal>

Keeping it DRY

You shouldn't have to copy-paste useBackDismiss into every single file that has a modal. Instead, use one of these two patterns to apply it globally:

1. The Reusable Wrapper Component (Recommended)

Create a wrapper around your UI library's dialog once, and use your wrapper throughout your app.

// components/AppDialog.jsx
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { useBackDismiss } from 'back-dismiss/react';

export function AppDialog({ open, onOpenChange, children }) {
  // Applied once, works everywhere this component is used
  useBackDismiss(open, () => onOpenChange(false));

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent>{children}</DialogContent>
    </Dialog>
  );
}
2. Global Modal Manager (State-driven)

If your app uses global state (Zustand, Context, Redux) to manage modals, put the hook inside your global provider.

// components/GlobalModalProvider.jsx
import { useBackDismiss } from 'back-dismiss/react';
import { useModalStore } from '@/store';

export function GlobalModalProvider() {
  const { isOpen, activeModal, closeModal } = useModalStore();

  // One hook controls the back button for the entire app!
  useBackDismiss(isOpen, closeModal);

  return (
    <>
      {activeModal === 'LOGIN' && <LoginModal onClose={closeModal} />}
      {activeModal === 'SETTINGS' && <SettingsModal onClose={closeModal} />}
    </>
  );
}

Unsaved Changes Guard

function EditForm({ open, onClose }) {
  const [dirty, setDirty] = useState(false);

  useBackDismiss(open, onClose, {
    onCancel: () => {
      if (dirty) return confirm('Discard unsaved changes?');
      return true; // Allow close
    },
  });

  return open ? (
    <form>
      <input onChange={() => setDirty(true)} />
    </form>
  ) : null;
}

Nested Overlays

Overlays automatically stack. Each back press closes the topmost one:

// User opens sidebar
BackDismiss.push(() => closeSidebar());

// User opens modal from sidebar
BackDismiss.push(() => closeModal());

// User opens confirm dialog from modal
BackDismiss.push(() => closeConfirmDialog());

// 1st Back → closes confirm dialog
// 2nd Back → closes modal
// 3rd Back → closes sidebar

Global Configuration

import { BackDismiss } from 'back-dismiss';

BackDismiss.configure({
  // Force a specific adapter
  adapter: 'auto', // 'auto' | 'close-watcher' | 'history'

  // Prevent app exit in standalone PWAs
  preventAppExit: false,
});

Manual Entry Management

const entry = BackDismiss.push({
  onClose: () => sidebar.close(),
  id: 'main-sidebar',
});

// Check stack depth
console.log(BackDismiss.depth()); // 1

// Manually dismiss
entry.dismiss();

// Or pop by ID
BackDismiss.pop('main-sidebar');

// Clear all entries (e.g., on route change)
BackDismiss.clear();

API Reference

BackDismiss (Core)

Method Description
push(onClose) Push a close callback. Returns a BackDismissEntry.
push(options) Push with full options (onClose, onCancel, id).
pop() Remove the topmost entry.
pop(id) Remove a specific entry by ID.
depth() Get the number of active entries.
clear() Remove all entries.
configure(config) Update global settings.

useBackDismiss(isOpen, onClose, options?) (React Hook / Vue Composable)

Parameter Type Description
isOpen boolean / Ref<boolean> / () => boolean Whether the overlay is currently open.
onClose () => void Callback to close the overlay.
options.onCancel () => boolean Return false to prevent closing.
options.id string Optional identifier for debugging.

How It Works

back-dismiss uses a tiered strategy to intercept the back button:

  1. CloseWatcher API (preferred) — A W3C standard that intercepts platform "close" signals (Android back button, Escape key) without polluting browser history. Supported in Chrome 120+, Safari 26.2+, Firefox 147+.

  2. History API (fallback) — Pushes a dummy history entry when an overlay opens. When the user presses back, popstate fires and the overlay closes. Works in all browsers. Carefully preserves existing history.state to avoid breaking framework routers.

Browser Support

Browser Method Used
Chrome 120+ / Edge / Opera CloseWatcher
Safari 26.2+ (iOS & macOS) CloseWatcher
Firefox 147+ CloseWatcher
Older browsers History API fallback

License

MIT

Related Packages

vue-vaul

A drawer component for Vue

9,592 0
pure-web-bottom-sheet

A performant, lightweight, and accessible bottom sheet web component powered by...

25,987 214
hcx

HTML Compiler - Routeless, targetless, remote, runnable routes and more ...

468
vaul-vue

Vaul Vue is an unstyled drawer component for Vue that can be used as a Dialog re...

11,323,700 497

Version History

Version Released Vue License
0.3.0 >=3.0.0 MIT
0.2.5 >=3.0.0 MIT
0.2.3 >=3.0.0 MIT
0.2.0 >=3.0.0 MIT
0.1.1 MIT