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

@reactleaf/modal

v2.0.0

Published

React Modal Library with context

Readme

@reactleaf/modal

npm version

A type-safe React modal library for opening modal components from anywhere in your code and receiving their results as Promise values when they close.

Overview

@reactleaf/modal is a React modal manager you can use to open modals from any layer of your app, including stacked modals. You can open confirmation, input, or alert modals on top of already open ones; each modal is pushed onto a stack in open order.

What changed in v2

@reactleaf/modal v2 drops the v1 register-based model and works by passing modal components directly.

Create a single ModalManager instance, wire it to ModalProvider, and call modal.open(Component, props, options) where you need to open a UI. Inside a modal, use useModalInstance() to read the current layer’s display state, a function to close that layer, and a function to replace that layer with another modal component.

Problems this aims to solve

  • Open modal components directly—no string names or register files—so SSR can still see imports and extract styles where tooling expects it.
  • Integrate with the browser Back button so the top modal can close when the user navigates back (especially relevant on Android devices).

Changes at a glance

  • No register.ts file.
  • useModal() and createModalHook() are removed.
  • Pass components directly instead of string modal names.
  • BasicModalProps is removed.
  • modal.open(Component, props?, options?) returns a Promise that resolves when the modal closes.
  • Modal components use useModalInstance() for visible, closeSelf, and replaceSelf.

Installation

npm install @reactleaf/modal
# or
yarn add @reactleaf/modal
# or
pnpm add @reactleaf/modal

Import the default stylesheet once in your app entry if you want the built-in layer and dim styles.

import '@reactleaf/modal/style.css';

Quick start

1. Create a manager instance

ModalProvider and every modal.open(...) caller must share the same ModalManager. Usually you create one in a module and export it.

// modal.ts
import { ModalManager } from '@reactleaf/modal';

export const modal = new ModalManager();

2. Wire up the provider

Pass your manager into ModalProvider.

import { ModalProvider } from '@reactleaf/modal';
import { modal } from './modal';

function App() {
  return (
    <ModalProvider
      manager={modal}
      defaultLayerOptions={{ closeDelay: 180, closeOnOutsideClick: true, dim: true }}
      rootOptions={{ preventScroll: true }}
    >
      <YourApp />
    </ModalProvider>
  );
}

ModalProvider renders children as-is and also renders a layer container for open modals.

3. Define a modal component

Modals are plain React components. visible is useful for open/close transitions; closeSelf(result) closes the current modal and resolves the modal.open(...) promise with result. The generic passed to useModalInstance<Result>() is only used to restrict the value accepted by closeSelf(...).

import { ModalComponent, useModalInstance } from '@reactleaf/modal';

export type ConfirmProps = {
  message: string;
};

export const Confirm: ModalComponent<ConfirmProps, boolean> = ({ message }) => {
  const { visible, closeSelf } = useModalInstance<boolean>();

  return (
    <div className={visible ? 'confirm visible' : 'confirm'}>
      <p>{message}</p>
      <button type="button" onClick={() => void closeSelf(false)}>
        Cancel
      </button>
      <button type="button" onClick={() => void closeSelf(true)}>
        OK
      </button>
    </div>
  );
};

// Per-component defaults (optional)
Confirm.layerOptions = {
  closeOnOutsideClick: false,
};

4. Open a modal and read the result

import { modal } from './modal';
import Confirm from './modals/Confirm';

async function deleteItem() {
  const confirmed = await modal.open(Confirm, {
    message: 'Delete this item?',
  });

  if (!confirmed) return;

  await requestDelete();
}

API

new ModalManager()

Creates a controller that owns the modal stack.

import { ModalManager } from '@reactleaf/modal';

export const modal = new ModalManager();

You may use multiple managers in one app, but every modal.open call must use the manager attached to the ModalProvider that wraps that part of the tree.

<ModalProvider manager={...} />

Renders the stack for a given manager.

<ModalProvider
  manager={modal}
  defaultLayerOptions={{ closeDelay: 180, dim: true }}
  rootOptions={{ preventScroll: true }}
>
  <App />
</ModalProvider>

Props:

  • manager: ModalManager
  • defaultLayerOptions?: Partial<LayerOptions>
  • rootOptions?: Partial<RootOptions>
  • children: React.ReactNode

modal.open(Component, props?, options?)

Opens a modal and returns a Promise that resolves when that layer closes.

const result = await modal.open(Alert, {
  message: 'Saved.',
});

If the modal has required props, pass them as the second argument. If it has no props or only optional props, you can omit the second argument. To pass only options for a no-props modal, use null as the second argument.

await modal.open(EmptyModal);
await modal.open(EmptyModal, null, { closeOnOutsideClick: false });

When a modal component is typed as ModalComponent<Props, Result>, modal.open(...) infers both its props and resolved value. For untyped components, use the second generic parameter when you want to spell out the resolved value. The actual resolved type can also include ModalAborted | ModalReplaced | undefined in addition to your result type.

const name = await modal.open(Prompt, {
  title: 'Enter a name',
});

What the Promise resolves to:

  • Closed from inside the modal
    • closeSelf(value)value
    • closeSelf()undefined
  • Closed from outside
    • Outside click (top layer) / Escape / browser back → undefined
    • modal.closeWithResult(id, value)value
    • modal.close(id) / modal.closeTop() / modal.closeAll()undefined
  • abortController.abort()MODAL_ABORTED
  • Replaced with replaceSelf(...) from inside the current layer
    • The previous modal’s open() promise → MODAL_REPLACED

modal.closeWithResult(id, result, options?)

Closes the modal with the given id and resolves its open() promise with result.

modal.closeWithResult(id, { confirmed: true });
modal.closeWithResult(id, { confirmed: true }, { historyBack: true });

modal.close(id, options?)

Closes the modal with the given id and resolves its open() promise with undefined.

modal.close(id);

modal.closeTop(options?)

Closes the top-most modal.

modal.closeTop();
modal.closeTop({ historyBack: true });

Close helpers like closeWithResult, close, and closeTop are no-ops when there is nothing applicable to close.

modal.closeAll(options?)

Closes every open modal.

modal.closeAll();

modal.hasOpenModals()

Returns whether at least one modal is open.

if (modal.hasOpenModals()) {
  console.log('A modal is open');
}

modal.getSnapshot()

Returns a read-only snapshot of the current stack.

const opened = modal.getSnapshot();

if (opened.some((item) => item.Component === Confirm)) {
  console.log('Confirm is already open');
}

Each item includes id, Component, props, and options.

modal.subscribe(listener)

Subscribes to stack changes. Uncommon in app code; useful for debug panels or external sync.

const unsubscribe = modal.subscribe((stack) => {
  console.log('open modals:', stack.length);
});

unsubscribe();

useModalInstance()

Call inside a modal component to read state and close/replace helpers for that layer.

const { visible, closeSelf, replaceSelf } = useModalInstance();

Exposed values:

  • visible: boolean
  • closeSelf(result?): Promise<void>
  • replaceSelf(Component, props?, options?): Promise

useModalInstance() relies on context, so it only works inside components opened via modal.open(...).

replaceSelf(Component, props?, options?)

Keeps the current layer (same id, backdrop behavior) and swaps the rendered content to another modal component. Resolves with the new modal’s result when that layer eventually closes. The previous open() promise resolves to MODAL_REPLACED.

import { useModalInstance } from '@reactleaf/modal';
import CodeModal from './CodeModal';

function EmailModal({ onVerified }: EmailModalProps) {
  const { replaceSelf } = useModalInstance();

  async function submitEmail(email: string) {
    await sendVerificationCode(email);

    const verified = await replaceSelf(CodeModal, {
      email,
    });

    if (verified) {
      await onVerified();
    }
  }
}

Modal options

export interface LayerOptions {
  className?: string;
  closeDelay?: number;
  closeOnOutsideClick?: boolean;
  dim?: boolean | string;
}

export interface RootOptions {
  preventScroll?: boolean;
}

export interface ModalOptions extends LayerOptions {
  abortController?: AbortController;
}

Option priority

Layer options merge in this order; later values override earlier ones.

  1. defaultLayerOptions on ModalProvider
  2. Component.layerOptions on the modal component
  3. Options passed to modal.open(...)
<ModalProvider
  manager={modal}
  defaultLayerOptions={{ closeOnOutsideClick: true, dim: true }}
>
  <App />
</ModalProvider>

Confirm.layerOptions = {
  closeOnOutsideClick: false,
};

await modal.open(Confirm, { message: 'Continue?' }, { className: 'confirm-layer' });

Component-level defaults

ModalComponent<Props, Result> lets you attach layerOptions to the component and expose the result type that modal.open(...) should infer.

import { ModalComponent } from '@reactleaf/modal';

type AlertProps = {
  message: string;
};

const Alert: ModalComponent<AlertProps> = ({ message }) => {
  return <div>{message}</div>;
};

Alert.layerOptions = {
  closeOnOutsideClick: false,
};

Promise-based examples

Confirmation

const confirmed = await modal.open(Confirm, {
  message: 'Are you sure you want to delete this?',
});

if (confirmed) {
  await deleteItem();
}

Prompt / input

const title = await modal.open(Prompt, {
  title: 'Document title',
  placeholder: 'Enter a title',
});

if (title) {
  await saveTitle(title);
}

AbortController

import { MODAL_ABORTED } from '@reactleaf/modal';

const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), 3000);

const result = await modal.open(
  Alert,
  { message: 'Closes automatically in 3s.' },
  { abortController: controller },
);

window.clearTimeout(timer);

if (result === MODAL_ABORTED) {
  console.log('Modal closed via abort');
}

Outside React

Anywhere you can import the manager, you can open a modal even outside components.

import { modal } from './modal';
import Alert from './modals/Alert';

window.addEventListener('error', () => {
  void modal.open(Alert, { message: 'Something went wrong.' });
});

Animation

A layer flips to the visible state one frame after mount. Use .modal-layer.visible for dim / fade, and data-content-visible for the inner content’s enter/exit animation.

When you need a close animation, set closeDelay to your CSS transition duration.

<ModalProvider manager={modal} defaultLayerOptions={{ closeDelay: 180 }}>
  <App />
</ModalProvider>
.modal-layer {
  opacity: 0;
  transition: opacity 180ms ease;
}

.modal-layer.visible {
  opacity: 1;
}

.modal-layer > * {
  transform: translateY(8px) scale(0.98);
  transition: transform 180ms ease;
}

.modal-layer[data-content-visible='true'] > * {
  transform: translateY(0) scale(1);
}

You can mirror the same visible flag inside the modal content.

const { visible } = useModalInstance();

Default behavior

  • Escape closes the top modal.
  • Browser back closes the top modal.
  • Unless closeOnOutsideClick is false, a click on the top layer’s backdrop closes it.
  • Modals stack in open order.
  • When rootOptions.preventScroll is true, body scroll is locked while any modal is open.
  • dim: true adds the dim class to that modal layer.
  • A string dim value adds that string as a custom dim class on the layer.

Styling

Import the default CSS:

import '@reactleaf/modal/style.css';

Useful selectors:

  • .modal-layer
  • .modal-layer.visible
  • .modal-layer[data-content-visible="true"]
  • .modal-layer.dim
.modal-layer {
  display: flex;
  align-items: center;
  justify-content: center;
}

.modal-layer.dim {
  background: rgba(0, 0, 0, 0.6);
}

.modal-layer[data-content-visible='true'] > * {
  transform: translateY(0) scale(1);
}

Use className on a layer for custom wrapper classes.

await modal.open(Confirm, { message: 'Continue?' }, { className: 'danger-modal-layer' });

Use a string dim to replace the default dim class.

await modal.open(Confirm, { message: 'Continue?' }, { dim: 'danger-dim' });

Smooth sequential flow

replaceSelf(...) is a good fit when a flow should move to the next step in the same shell—e.g. email field, then verification code—without the backdrop flashing off and on.

import { type ModalComponent, useModalInstance } from '@reactleaf/modal';
import { modal } from './modal';
import CodeModal from './CodeModal';
import EmailModal from './EmailModal';

type EmailModalProps = {
  onVerified: () => Promise<void>;
};

function startEmailVerification() {
  void modal.open(EmailModal, {
    onVerified: completeSignIn,
  });
}

const EmailModal: ModalComponent<EmailModalProps, never> = ({ onVerified }) => {
  const { replaceSelf } = useModalInstance();

  async function submitEmail(email: string) {
    await sendVerificationCode(email);

    const verified = await replaceSelf(CodeModal, {
      email,
    });

    if (verified) {
      await onVerified();
    }
  }
};

The earlier open() promise resolves to MODAL_REPLACED; the promise returned from replaceSelf() resolves with the new modal’s result. The layer (and dim) stay mounted while only the inner content animates out and in.

Live demo

Run the app under [docs/app](./docs/app):

pnpm install
pnpm dev:docs