iOS and Android
Finish native setup and handle the behavior that differs between iOS and Android.
The portal, modal components, and typed results are shared. Native installation and system dismissals have platform-specific setup.
Native installation
Expo projects should use the Expo guide. In a bare React Native project, install every peer:
pnpm add magic-modal react-native-gesture-handler react-native-reanimated react-native-worklets react-native-screensAdd the Worklets plugin last:
module.exports = function (api) {
api.cache(true);
return {
presets: ["module:@react-native/babel-preset"],
plugins: [
// Existing plugins
"react-native-worklets/plugin",
],
};
};Install iOS pods after changing native dependencies:
npx pod-installMagic Modal 9 uses Reanimated 4 and requires React Native's New Architecture.
Platform behavior
| Behavior | iOS | Android |
|---|---|---|
| Backdrop press | Resolves with BACKDROP_PRESS | Resolves with BACKDROP_PRESS |
| Swipe dismissal | Resolves with SWIPE_COMPLETE | Resolves with SWIPE_COMPLETE |
| System back action | No matching system action | Resolves with BACK_BUTTON_PRESS |
| Accessibility escape | Resolves with BACK_BUTTON_PRESS | Resolves with BACK_BUTTON_PRESS |
| Full-window overlay | Enabled by default through Screens | No-op |
| Intentional close | Resolves with data on both platforms | Resolves with data on both platforms |
Android back action
The active modal consumes the Android back action and closes by default. Handle the close reason in the caller:
const result = await magicModal.show<CheckoutResult>(CheckoutModal);
switch (result.reason) {
case MagicModalHideReason.INTENTIONAL_HIDE:
return submitCheckout(result.data);
case MagicModalHideReason.BACK_BUTTON_PRESS:
return saveCheckoutDraft();
case MagicModalHideReason.BACKDROP_PRESS:
case MagicModalHideReason.SWIPE_COMPLETE:
case MagicModalHideReason.GLOBAL_HIDE_ALL:
return;
}Override onBackButtonPress when the modal needs a guard before closing:
magicModal.show(DestructiveActionModal, {
onBackButtonPress: ({ hide }) => {
if (canLeaveDestructiveAction()) {
hide({ reason: MagicModalHideReason.BACK_BUTTON_PRESS });
}
},
});An override controls whether and when hide runs. Leaving the callback without calling hide keeps
the modal open.
iOS full-window overlay
The portal uses react-native-screens to render above native modal screens on iOS. The overlay is
enabled by default.
Temporarily disable it when a native picker should cover application modals:
magicModal.disableFullWindowOverlay();
try {
await openImagePicker();
} finally {
magicModal.enableFullWindowOverlay();
}Both functions are no-ops on Android and web.
Backdrop and swipe policy
The default backdrop closes the top modal. Override it when a flow requires a guard:
magicModal.show(UnsavedFormModal, {
onBackdropPress: ({ hide }) => {
if (canDiscardDraft()) {
hide({ reason: MagicModalHideReason.BACKDROP_PRESS });
}
},
});Swipe dismissal defaults to "down". Disable it for forms and lists where the gesture competes with
scrolling:
magicModal.show(ScrollableFormModal, {
swipeDirection: undefined,
});Safe areas and keyboards
Magic Modal positions the overlay. Modal content still owns its safe-area padding, keyboard
avoidance, maximum height, and scrolling. Compose the same React Native primitives already used by
the application, such as SafeAreaView, KeyboardAvoidingView, and ScrollView.
Keep long forms below the device height and disable modal swipe when the form itself scrolls.