Note
Magic Modal is a headless orchestration primitive: show modal content from anywhere, await a typed result, and keep styling in your own components.
Tip
The complete guides and API reference live in the documentation.
- π² Easy Integration: Seamlessly integrate with your React Native app.
- π Complex Flow Management: Manage intricate modal sequences effortlessly.
- π§ Customizable: Tailor modals to fit your app's unique requirements.
React Native Magic Modal offers a superior experience compared to traditional modal implementations:
- π¨ Bring Your Own UI: Style ordinary React Native content for iOS and Android.
- π Developer Friendly: Simple to use, with a focus on developer experience.
- π§© Versatile: Adaptable to a wide range of modal scenarios.
Install the package and its native peers:
pnpm add react-native-magic-modal react-native-gesture-handler react-native-reanimated react-native-worklets react-native-screensMinimum peer versions:
| Peer | Minimum |
|---|---|
react |
18.0.0 |
react-native |
0.81.0 |
react-native-gesture-handler |
2.20.0 |
react-native-reanimated |
4.1.0 |
react-native-worklets |
0.5.0 |
react-native-screens |
4.19.0 |
Both gesture-handler majors work. Swipe-to-dismiss uses 3.x's usePanGesture hook when it's available and falls back to 2.x's Gesture.Pan() builder otherwise.
Reanimated 4 requires React Native's New Architecture. In a bare React Native app, add "react-native-worklets/plugin" last in babel.config.js, then run npx pod-install. Expo configures the plugin through its Babel preset; install compatible native versions with:
npx expo install react-native-gesture-handler react-native-reanimated react-native-worklets react-native-screens
pnpm add react-native-magic-modal8.0.0 was the one version that required gesture-handler 3.x. If you're on it and pinned to 2.x, upgrade to 9.0.0 or later, and drop react-native-gesture-handler from expo.install.exclude if you added it to quiet the version check.
Insert a MagicModalPortal at the top of your application structure, and a GestureHandlerRootView if you haven't already:
import { MagicModalPortal } from "react-native-magic-modal";
import { GestureHandlerRootView } from "react-native-gesture-handler";
export default function App() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<YourAppContent />
<MagicModalPortal /> {/** After your app component hierarchy */}
</GestureHandlerRootView>
);
}Tip: the root _layout.tsx is usually the best place to put it in a project using expo-router.
The GestureHandlerRootView is required. The portal renders a GestureDetector for the swipe gesture, and gesture-handler 3.x throws when one renders without a root view above it. 2.x only logs a warning.
Showcasing modal management on iOS and Android platforms:
| iOS | Android |
|---|---|
![]() |
![]() |
Here's the preferred usage pattern for the library:
import React from "react";
import { View, Text, TouchableOpacity } from "react-native";
import { magicModal, useMagicModal, MagicModalHideReason } from "react-native-magic-modal";
type ConfirmationModalReturn = {
success: boolean;
};
const ConfirmationModal = () => {
const { hide } = useMagicModal<ConfirmationModalReturn>();
return (
<View>
<TouchableOpacity onPress={() => hide({ success: true })}>
<Text>Click here to confirm</Text>
</TouchableOpacity>
</View>
);
};
const ResponseModal = ({ text }) => {
const { hide } = useMagicModal();
return (
<View>
<Text>{text}</Text>
<TouchableOpacity onPress={() => hide()}>
<Text>Close</Text>
</TouchableOpacity>
</View>
);
};
const handleConfirmationFlow = async () => {
// You can call `show` with or without props, depending on the requirements of the modal.
const result = await magicModal.show<ConfirmationModalReturn>(() => <ConfirmationModal />)
.promise;
// Hide could potentially be a backdrop press, a back button press, or a swipe gesture.
if (result.reason !== MagicModalHideReason.INTENTIONAL_HIDE) {
// User cancelled the flow
return;
}
if (result.data.success) {
return magicModal.show(() => <ResponseModal text="Success!" />).promise;
}
return magicModal.show(() => <ResponseModal text="Failure :(" />).promise;
};
export const MainScreen = () => {
return (
<TouchableOpacity onPress={handleConfirmationFlow}>
<Text>Start the modal flow!</Text>
</TouchableOpacity>
);
};You can also hide modals imperatively outside of the modal context. For that, we provide the global hide method, that requires a modal id:
import { magicModal } from "react-native-magic-modal";
const QuickModal = ({ text }) => {
return (
<View>
<Text>Hey! I'm going to be closed imperatively</Text>
</View>
);
};
const handleQuickModal = async () => {
const { modalID } = magicModal.show(QuickModal);
// Wait for 2 seconds before closing the modal
await new Promise((resolve) => setTimeout(resolve, 2000));
// Note that it's usually preferable to use the `hide` method from the modal context
// You can even put it inside useEffects to handle auto-dismissal for you.
magicModal.hide(undefined, { modalID });
};
export const MainScreen = () => {
return (
<TouchableOpacity onPress={handleQuickModal}>
<Text>Show a quick modal</Text>
</TouchableOpacity>
);
};show also hands you an update function, for when the data driving the modal lives outside of it:
import { magicModal } from "react-native-magic-modal";
const UploadModal = ({ progress }) => (
<View>
<Text>Uploading, {progress}%</Text>
</View>
);
const handleUpload = async (file) => {
const { modalID, update } = magicModal.show(() => <UploadModal progress={0} />);
await uploadFile(file, {
onProgress: (progress) => update(() => <UploadModal progress={progress} />),
});
magicModal.hide(undefined, { modalID });
};The modal itself stays put: same position in the stack, same backdrop, and the promise from show keeps waiting. Only the content is swapped, and it's a new component, so it mounts from scratch and anything it kept in useState is gone. If the state belongs to the modal, drive it from inside with a store or context instead.
Refer to the kitchen-sink example for detailed usage scenarios.
Access the complete documentation here.
Q: Can I have two modals showing up at the same time?
A: Yes. With v4+, you can now have multiple modals showing up at the same time.
Q: Can I use Scrollables inside the modal?
A:
Yes, but Scrollables can't be used with swipe gestures enabled, as they conflict. Pass in swipeDirection: undefined on the magicModal.show function to disable gestures on them.
If your use-case is a scrollable bottom-sheet, I recommend going with Gorhom's react-native-bottom-sheet for this use-case temporarily.
Q: Modals are appearing on top of native modal screens, such as the image picker. How can I fix this?
A:
This behavior can be disabled by calling magicModal.disableFullWindowOverlay() before showing the modal. This will prevent the modal from appearing on top of native modal screens.
You can also call magicModal.enableFullWindowOverlay() to re-enable it.
Special thanks to everyone who contributed to making React Native Magic Modal a robust and user-friendly library. See the full list.
See the contributing guide to learn how to contribute to the repository.
React Native Magic Modal is licensed under the MIT License.

