Open a dialog, await a typed result, and continue your application flow. Input and result types follow the modal definition or registry key; each ModalProvider owns an independent lifecycle.
Use this for repeated selection, editing and confirmation flows. A few local windows may only need component state. The core manages lifecycle; your dialog primitives supply the shell, portal, styling and accessibility policy.
pnpm add @okyrychenko-dev/react-modal-managerReact 18 or 19 is the only peer dependency. Examples below use guards from @okyrychenko-dev/type-utils; add it as a direct dependency when copying those imports:
pnpm add @okyrychenko-dev/type-utilsUse the complete plain React + Radix recipe in an existing React application. It includes installation, styling, confirmation content, renderer, Provider and caller, with no Next.js runtime required.
For a larger runnable application, use the Next.js + Radix example. Its setup guide installs the packed library and starts a complete example with a portal, overlay, focus containment, focus restoration, a custom confirmation surface and handled outcomes. It also covers handle dismissal, launcher cleanup and reopening. Radix and Next.js are example dependencies, not core dependencies.
The application combines these pieces:
DialogRendererowns the Radix root, portal and overlay;DialogSurfacesupplies the shared content surface and focus restoration.ConfirmationContentcomposesDialogSurfacewith title, description, confirmation policy and typed Confirm/Cancel results.DialogExampleinstalls both on oneModalProvider.
Inside that configured Provider, a caller can await the inferred confirmation result:
"use client";
import {
ModalDismissError,
useModalManager,
} from "@okyrychenko-dev/react-modal-manager";
import { isInstanceOf } from "@okyrychenko-dev/type-utils";
import { useState } from "react";
import type { ReactNode } from "react";
export function ConfirmationButton(): ReactNode {
const modal = useModalManager();
const [outcome, setOutcome] = useState("Ready");
async function handleClick(): Promise<void> {
try {
const result = await modal.confirm({
title: "Delete report?",
description: "This demo does not change your data.",
});
setOutcome(
result.confirmed ? "Confirmed" : `Cancelled: ${result.reason}`,
);
} catch (error: unknown) {
if (isInstanceOf(error, ModalDismissError)) {
setOutcome(`Dismissed: ${error.reason}`);
} else {
setOutcome("Failed");
console.error(error);
}
}
}
return (
<>
<button onClick={() => void handleClick()}>Open confirmation</button>
<p role="status">{outcome}</p>
</>
);
}result.confirmed narrows the result; the negative branch exposes result.reason. This checked caller reports an outcome without modifying data. For application work, perform the action only in the confirmed branch and keep it inside the same error-handling boundary. A caller's own unmount does not cancel a Provider-owned operation; use explicit launcher ownership when the caller owns that lifetime.
| Action | Promise outcome |
|---|---|
| Confirm | Resolves { confirmed: true } |
| Cancel in the Radix example or built-in confirmation | Resolves { confirmed: false, reason: "cancel" } |
| Escape or outside interaction in the Radix example (when dismissible) | Rejects with ModalDismissError, reason "dismiss" |
| Escape or Dismiss button in built-in reference confirmation | Resolves { confirmed: false, reason: "dismiss" } when dismissible |
handle.dismiss() or manager/registry dismiss(id) |
Rejects with ModalDismissError, default reason "dismiss" |
closeAll() |
Rejects pending operations with reason "close-all" |
| Provider unmount | Rejects pending operations with reason "provider-unmount" |
Custom content close(result) / reject(error) |
Resolves the typed result / rejects; non-Error rejections become ModalRejectError |
For custom dialogs, the component chooses whether Cancel returns a result or dismisses. The result settles once, immediately; closeDelayMs can keep a closing instance rendered afterward. Use await handle.whenRemoved() to wait for lifecycle removal after that delay or provider disposal. This does not await a React DOM commit or a renderer's animation callback. See typed flows and cleanup.
A bare <ModalProvider> uses the bundled unstyled reference confirmation. It has basic dialog/focus behavior but provides no overlay, portal, background inertness or pointer isolation. It is useful for inspecting the contract; use the complete Radix path above for application integration. Do not put the built-in confirmation inside a renderer that already supplies dialog semantics; compose exactly one dialog content surface with the Radix renderer, as in the example.
Define a component with ModalComponentProps<TInput, TResult>, create its definition with createModal(), then call modal.open(definition, input). The returned ModalHandle<TResult> is awaitable and exposes instanceId, dismiss() and whenRemoved(). See the typed flow guide and checked adoption examples.
Keep the returned handle for deferred cancellation. Use a separate createModalRegistry() per independent scope when opening from commands outside React. Ordinary descendant useEffect callbacks can open through their registry on mount; layout effects have no readiness guarantee. Activity hiding ends pending operations with provider-unmount; revealing permits new operations. A shared registry routes to its latest attached Provider; an instance ID belongs only to its lifecycle. handle.dismiss() retains the original owner even after another Provider mounts. Run the two-scope example.
The current checkout exports useOwnedModalHandles() for opt-in launcher ownership. owner.track(handle) preserves the typed result, forgets settled handles and dismisses pending handles during cleanup. Capture const isActive = owner.captureLifetime() before starting async work and check it before updating local state or opening the next step. Keep the original handle for individual dismissal and removal waiting; the tracked promise has no handle members. This hook is unreleased and is not available in the pinned npm 0.2.0 baseline.
ModalProvider renders its viewport automatically. Use renderer for a shell or portal; adding another viewport renders the same instances twice. Modal content receives context from the Provider's rendering location, so pass launcher-only data as typed input or place the relevant context above the Provider.
- Registries, scope and context
- Dialog integration and framework recipes
- Public API reference
- Package evidence and development commands
From a repository checkout, pnpm run check:examples builds the public package and checks the consumer snippets. pnpm run preview:nextjs starts a temporary production example against the current packed library and prints its URL; Ctrl+C stops it and removes the copy. pnpm run test:nextjs:unit runs the Next.js example's own typechecks and ownership tests in an isolated packed installation. pnpm run test:nextjs also lints that example, builds production output and exercises Chromium. pnpm run build followed by pnpm run storybook opens the interactive examples; pnpm run test:storybook runs their browser checks. Browser commands require Playwright Chromium (pnpm exec playwright install chromium). These checks do not establish full accessibility certification or support for every framework/browser combination.
MIT © Oleksii Kyrychenko