back-dismiss
| Install | |
|---|---|
npm install back-dismiss |
|
| Latest Version: | 0.3.0 |
| Vue: | 3 |
| License: | MIT |
| Last Updated: | Oct 19, 2026 |
| Links: | GitHub · npm |
back-dismiss
Make the mobile back button close your overlays instead of navigating away.
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-dismissprovides 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:
-
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+.
-
History API (fallback) — Pushes a dummy history entry when an overlay opens. When the user presses back,
popstatefires and the overlay closes. Works in all browsers. Carefully preserves existinghistory.stateto 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
Related Packages
A performant, lightweight, and accessible bottom sheet web component powered by...
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 | ||
| 0.1.0 | MIT |