Library / SDK
GSTJ/react-native-magic-modal avatar
GSTJ/react-native-magic-modal

react-native-magic-modal: imperative modals with awaitable results

A modal library that can be called imperatively from anywhere. Effortlessly control modals, streamline complex flows, and create a reliable user experience.

646 stars16 forksTypeScriptMIT

At a glance

What is it?
A TypeScript modal library that mounts one portal and lets any async flow open a modal and await a typed result. This review covers the install paths, the handle contract, and where the design stops paying off.
Who is it for?
Adopt it if your app already has flows that want to pause on a decision and resume with data, and you are on Expo, bare React Native, or a browser-only React app. Skip it if you need snap points, nested scrolling, or a modal that lives inside a screen's own navigation state.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 4 days ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on September 25, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The problem react-native-magic-modal solves

React Native's built-in `Modal` is a component. You render it, you hold a boolean in state, and you pass callbacks down. That works when the modal belongs to one screen. It stops working when the modal is a step in a longer process: confirm this destructive action, pick an address, ask for a reason, then continue. The state that decides whether to show the modal and the async function that needs the answer live in different places, so you end up threading props or lifting state until the screen component knows about things it should not.

react-native-magic-modal inverts that. You mount `MagicModalPortal` once near the root, and from then on any code path can call `magicModal.show()` and await the result. The project's own description of the flow is that the portal owns the modal stack, `show()` pushes one entry and returns an awaitable handle, and `useMagicModal().hide(data)` closes that entry with typed data.

The audience is narrow and specific. It is for React Native and Expo developers who write async flows, and for browser React developers who want the same call style without React Native in the bundle. It is not for someone who needs a general-purpose overlay system with snap points and gesture-driven sheets.

How the portal, the stack entry and the handle fit together

The architecture has four moving parts. `MagicModalPortal` owns the modal stack. `magicModal.show()` pushes an entry and returns a handle. `useMagicModal().hide(data)` closes that entry with typed data. The handle resolves to `HideReturn<T>`, including the close reason. The README is explicit that the handle is the promise itself, carrying that entry's `modalID`, `update`, and `hide`.

Every stack entry keeps its own component, configuration, ID, and promise. That is the part that matters for anything beyond a single confirmation. A second `show()` call can open above the current modal without mixing their results, so a flow that asks two questions in sequence does not need to coordinate a global "current modal" variable.

The result type is shared between the opener and the modal. You call `magicModal.show<ConfirmationResult>(ConfirmationModal, ...)` and inside the modal you call `useMagicModal<ConfirmationResult>()`. TypeScript exposes `data` after the caller narrows the result to `INTENTIONAL_HIDE`.

The close reason is not a boolean. Backdrop presses, completed swipes, system-dismiss actions, and `hideAll()` resolve the same promise with distinct reasons, and system dismissal covers Android back, web Escape, and the native accessibility escape action. That distinction is the design's best idea: a cancelled flow and a dismissed flow are different events, and the code that resumes after the await can branch on them without guessing.

One detail worth flagging. `const { promise, modalID, update } = magicModal.show(...)` still works, but `promise` is a deprecated alias of the handle itself. New code should await the handle directly.

Installing react-native-magic-modal and opening the first modal

The package name on npm is `magic-modal`. The install differs by platform, and the README is clear about why: the web entry ships with zero React Native dependencies, while the native entries use the full React Native stack.

For Expo on iOS or Android, add the package and let Expo install the native peer set:

bash
pnpm add magic-modal
npx expo install react-native-gesture-handler react-native-reanimated react-native-worklets react-native-screens

For a browser-only React app such as Next.js, the install is a single command:

bash
pnpm add magic-modal

The README states that this is the whole install for the browser, that no bundler alias is needed, and that there is no gesture or animation peer. Next.js users should copy the Client Component setup from the Next.js guide, and there is a runnable App Router consumer in `examples/next-web`.

After installing, mount the portal once. On native, it goes inside `GestureHandlerRootView`:

tsx
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { MagicModalPortal } from "magic-modal";

export default function App() {
  return (
    <GestureHandlerRootView style={{ flex: 1 }}>
      <YourApp />
      <MagicModalPortal />
    </GestureHandlerRootView>
  );
}

With Expo Router, the README says to put the same structure in the root `app/_layout.tsx`. In a browser application you mount `MagicModalPortal` inside a Client Component and nothing else, because there is no Gesture Handler in the browser bundle.

Then open a modal from any async function. The README's example awaits the handle and branches on the close reason:

tsx
const result = await magicModal.show<ConfirmationResult>(ConfirmationModal, {
  accessibilityLabel: "Confirm publish",
});

if (result.reason === MagicModalHideReason.INTENTIONAL_HIDE) {
  await publish(result.data);
} else {
  recordCancellation(result.reason);
}

Inside the modal component, `useMagicModal<ConfirmationResult>()` gives you `hide`, and calling `hide({ confirmed: true })` resolves the awaiting caller. The README notes an accessibility detail here: the browser entry renders DOM, so the same modal on the web is a `<section>` with `<h2>` and `<button>` and imports nothing from React Native, while the result contract stays identical.

Where the modal stack model breaks down

The README is candid about scope in its own FAQ. Magic Modal does not implement snap points or nested scrolling. If you want a bottom sheet that a user can drag to three heights, this is the wrong library, and the install list is a hint about why: the native peers are gesture handler and reanimated, but the library uses them for dismissal and presentation, not for a sheet's detents.

A modal containing a ScrollView is supported, but only by turning off swipe dismissal:

tsx
magicModal.show(ScrollableModal, {
  swipeDirection: undefined,
});

That is a real trade-off rather than a bug. A swipe-to-dismiss gesture and a vertical scroll gesture compete for the same touch, and the library resolves the conflict by asking you to choose. You either get swipe dismissal or a scrollable body, not both, unless you build the gesture arbitration yourself.

The second constraint is structural. Because the portal owns the stack, the modal is not part of any screen's navigation state. Deep linking into a modal, restoring a modal after a state reload, or letting a navigator animate it as a screen is not what this is built for. The imperative call style is the feature and the limitation at once: the modal is reachable from anywhere, which also means it is not owned by anywhere.

There is also a maintenance signal to read carefully. The last push to the repository was on 2026-07-31, and the release list shows 9.2.0, 10.1.1, and 10.2.0 all dated the same day. That is a burst of release activity rather than a steady cadence, and the version jump from 9.2.0 to 10.x within hours suggests a major change landed in that window. If you pin a version, read the changelog before moving across that boundary.

How it differs from Modalfy and from a plain React Native Modal

The closest comparison in the search results is `react-native-modalfy`, which also manages a stack of modals and also exposes an imperative API. The difference in approach is in what the caller receives. Modalfy's model centres on named modals registered in a config and opened by key; the caller does not get a promise back from the open call. react-native-magic-modal's handle is the promise, and the README makes the resolution contract the centrepiece: the caller resumes with submitted data or the exact dismissal reason.

That matters for how you write the calling code. With a promise-returning handle, an async flow reads top to bottom: show, await, branch on reason, continue. With a callback or event model, the continuation has to be registered somewhere and the flow is split across two functions.

The comparison to the built-in `Modal` component is simpler. The built-in component gives you full control over presentation and no opinion about who opens it. react-native-magic-modal gives you an opinion about who opens it and, in exchange, removes the state plumbing. If your app has exactly one modal on one screen, the built-in component is less machinery. The library earns its place when modals are steps in flows rather than decorations on screens.

Licence, monorepo layout and upgrade cost

The licence is MIT, stated in `LICENSE` and in the repository `package.json`. MIT permits commercial use, modification, and redistribution with the copyright notice retained. That is a permissive baseline, and it is the same licence the surrounding tooling uses. Nothing in the repository suggests a dual-licence arrangement or a commercial tier, but this is not legal advice, so a team with unusual redistribution requirements should read the file itself.

The repository is a pnpm and Turborepo workspace. The root `package.json` is private, named `magic`, and pins `[email protected]`. Scripts run through `turbo`, with `build`, `test`, `lint`, `typecheck`, and `doctor` defined at the root. The published package is filtered as `magic-modal`, and the release script is `pnpm --filter magic-modal run release`. There is also a codemod package in devDependencies, `magic-codemods`, which is a signal that the project has shipped breaking changes and expects consumers to migrate mechanically.

For an application consuming the library, the upgrade cost is mostly the peer set. The Expo install commands pull `react-native-gesture-handler`, `react-native-reanimated`, `react-native-worklets`, and `react-native-screens`, and reanimated and worklets are version-sensitive against the Expo SDK. The README's framing is that Expo chooses versions compatible with the installed SDK, which is the right way to install them. The browser install has none of that weight, so a Next.js consumer's upgrade surface is much smaller than a native consumer's.

The `CHANGELOG.md` at the repository root is the place to check before crossing a major version. Given that three releases landed on 2026-07-31 including a jump to 10.x, a consumer on 9.x should read that file rather than assume a patch-level change.

Editorial conclusion

Adopt it if your app already has flows that want to pause on a decision and resume with data, and you are on Expo, bare React Native, or a browser-only React app. Skip it if you need snap points, nested scrolling, or a modal that lives inside a screen's own navigation state. Before committing, verify the platform guide for your target matches your bundler setup, and check that the peer set from the install commands resolves in your lockfile.

Frequently asked questions

Can react-native-magic-modal keep multiple modals open at once?

Yes. Every `show()` call creates an independent stack entry with its own ID, configuration, and promise, and the README states that a second `show()` call can open above the current modal without mixing their results.

Can a modal in react-native-magic-modal contain a ScrollView?

Yes, but you must disable swipe dismissal by passing `swipeDirection: undefined` to `magicModal.show()`. The README states that Magic Modal does not implement snap points or nested scrolling.

How do I close a react-native-magic-modal modal from outside its own component?

Keep the `modalID` returned by `show()` and call `magicModal.hide(undefined, { modalID })`. The README gives this as the pattern for closing from outside the modal component.

What does react-native-magic-modal return when a modal is dismissed rather than answered?

The handle resolves with a close reason instead of submitted data. Backdrop presses, completed swipes, system-dismiss actions, and `hideAll()` each resolve the same promise with distinct reasons, and system dismissal covers Android back, web Escape, and the native accessibility escape action.

Official sources

  1. Official documentation
  2. Official README
  3. Project repository
  4. Release notes
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/gstj-react-native-magic-modal.svg)](https://hysenlabs.com/projects/gstj-react-native-magic-modal)