Magic Modallatest

A modal stack for React Native

React Native modals you can await

Mount one MagicModalPortal at the app root. Call show() from any async flow and await the typed result.

rating-flow.tsx
01const rating = await magicModal
  .show<RatingAnswer>(RatingModal).promise;02await magicModal.show(
  rating.data.score < 4
    ? FeedbackModal : StoreReviewModal
).promise;03await magicModal
  .show(ThanksModal).promise;04return rating;
promise pending
MagicModalPortalSTACK: 1 ENTRY
9:41
Moments
01RatingModal

Rate the app

How would you rate the app from zero to five?

Scores 0 through 3 open feedback. Scores 4 and 5 open the store prompt.
Why it exists
WHY IT EXISTS

Two callers can open a modal at the same time

The first Magic Modal flow asked for an app rating after a like. It could start from several screens and branch to feedback or the store. Both paths ended with a thank-you.

SCREEN OWNED4 pieces of visibility state

Every screen that can start the flow repeats the modal tree and its cleanup.

PORTAL OWNED4 callers share one function

The flow lives outside the screens. Each caller awaits the same sequence.

IDLE0 modal bodies mounted

The portal stays empty until show() runs.

OPENOne ID and promise per entry

Concurrent prompts keep their own place in the stack.

CODE4 callers share startRatingFlow()

The shared flow owns the modal components and visibility state.

See the portal tests
LIVE PACKAGE

Test two calls in one stack

Start the rating flow and stack a notification over it. Close the top entry and the first promise is still waiting underneath.

ACTUAL PACKAGEExpo / iOS / Android / Web
0open entries
ONE PORTAL ON THIS PAGE

Stack a notification over the rating prompt

Start the rating flow in the phone frame, or run update() in the web panel. This page mounts one MagicModalPortal for both.

CALLERrating-flow.ts
const result = await magicModal
  .show(RatingPrompt)
  .promise
1 portal1 result per entry
ACTIVE STACKTop entry stays visible
0

The stack is empty.Modal content mounts after show() runs.

PROMISE RESULTSEach promise resolves separately

No calls yet.Resolved values appear here.

COMMON FLOWS

Await a result or update the open modal

Each action resolves or updates the current modal. The result decides what opens next.

delete-post.tsx
const result = await magicModal
  .show<Confirmation>(() => (
    <DeletePostModal />
  ))
  .promise;

if (
  result.reason === INTENTIONAL_HIDE &&
  result.data.confirmed
) {
  await deletePost(postID);
}
MagicModalPortal1 entry

Delete this post?

This action cannot be undone.

RESULTPromise pending
APP ROOT

Mount one portal at the app root

Modal calls can come from anywhere in the app. Each show() gets an ID and promise, so one flow cannot overwrite the modal that was already open.

See the flow pattern
CALLERSpost.tsxcomment.tsxproduct.tsxnotifications.ts
FLOWstartRatingFlow()rating(); branch(); thanks();
APP ROOTMagicModalPortal
RETURN VALUE

What magicModal.show() returns

Await the close result, target this stack entry by ID, or replace its component while the same promise stays pending.

{
  promise: Promise<HideReturn<T>>;
  modalID: string;
  update: (next: React.FC) => void;
}
AVAILABLE IMMEDIATELY
promise01
Resolves when this entry closes.
modalID02
Targets this entry from another call site.
update03
Replaces the component while its ID, backdrop, stack position, and pending promise stay put.

UPDATE REMOUNTSupdate() starts a fresh component mount. Local React state resets.

CLOSE RESULTS

What HideReturn<T> records

A submitted answer, backdrop tap, swipe, Android back press, and hideAll() resolve differently. Only hide(data) returns a payload.

Read HideReturn<T>
Trigger any close path
Choose an answer

Answer from the sheet or dismiss it another way.

HideReturn<Answer>promise pending
const result = await handle.promise;
// waiting for a close
Press an action to resolve the promise
RELEASE HISTORY

How the API changed

Apps needed stacked modals, targeted updates, typed close reasons, native overlays, and support for newer gesture APIs.

  1. 2022FEB 21, 2022First release.

    show() moved visibility state out of the screen and returned a value when the modal closed.

  2. 2024JUN 08, 2024Stacks and typed close reasons.

    Each entry gained its own ID and promise. This release also added hideAll(), swipe gestures, and the iOS full-window overlay.

  3. 2026JUL 27, 2026In-place updates and Gesture Handler 3.

    update() can replace an open modal in place. Swipe dismissal now supports Gesture Handler 2 and 3.

GET STARTED

Add MagicModalPortal to your app root

The setup guide mounts the app-root portal and shows how to await a typed HideReturn<T>.

Set up the portal