npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

back-dismiss

v0.3.0

Published

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

Readme

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