Expo
Set up Magic Modal for Expo Web, iOS, Android, or a shared project.
Add Magic Modal once:
pnpm add magic-modalExpo selects peer versions that match the installed SDK. Install the set for each runtime the project ships.
Expo Web
Install the gesture and animation runtime plus Expo's browser renderer:
npx expo install react-native-gesture-handler react-native-reanimated react-native-worklets react-dom react-native-web @expo/metro-runtimeUse the shared application root. Start the browser target:
npx expo start --webThis path keeps the same React Native component and HideReturn<T> contract used by the native
targets.
Expo iOS
Install the native runtime:
npx expo install react-native-gesture-handler react-native-reanimated react-native-worklets react-native-screensUse the shared application root. Build the iOS target:
npx expo run:iosThe native entry can place the portal above iOS modal screens. See native iOS overlays for picker layering.
Expo Android
Install the native runtime:
npx expo install react-native-gesture-handler react-native-reanimated react-native-worklets react-native-screensUse the shared application root. Build the Android target:
npx expo run:androidAndroid's system back action closes the top stack entry and resolves its promise with
BACK_BUTTON_PRESS.
Shipping iOS and Android
The native dependency commands are identical. Run the install once. Use the platform command for each target.
Shared application root
Mount one MagicModalPortal after the router and keep it inside GestureHandlerRootView:
import { Stack } from "expo-router";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { MagicModalPortal } from "magic-modal";
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<Stack />
<MagicModalPortal />
</GestureHandlerRootView>
);
}The portal stays mounted while navigation changes. Every show() call pushes one entry onto its
stack.
An Expo application without Expo Router uses the same structure. Replace Stack with the
application component.
Expo's Babel preset configures Worklets. A default Expo project does not need a custom Babel plugin entry.
Share one modal flow
Use React Native primitives in content that runs on every Expo target:
import { Pressable, StyleSheet, Text, View } from "react-native";
import { useMagicModal } from "magic-modal";
export type RatingResult = {
rating: number;
};
export function RatingModal() {
const { hide } = useMagicModal<RatingResult>();
return (
<View style={styles.card}>
<Text accessibilityRole="header" style={styles.title}>
How did that feel?
</Text>
<View style={styles.row}>
{[1, 2, 3, 4, 5].map((rating) => (
<Pressable
accessibilityLabel={`${rating} out of 5`}
accessibilityRole="button"
key={rating}
onPress={() => hide({ rating })}
style={styles.rating}
>
<Text>{rating}</Text>
</Pressable>
))}
</View>
</View>
);
}
const styles = StyleSheet.create({
card: {
alignSelf: "center",
backgroundColor: "white",
borderRadius: 24,
gap: 20,
maxWidth: 420,
padding: 24,
width: "90%",
},
title: {
fontSize: 20,
fontWeight: "600",
},
row: {
flexDirection: "row",
gap: 8,
justifyContent: "space-between",
},
rating: {
alignItems: "center",
backgroundColor: "#f1f1f1",
borderRadius: 999,
flex: 1,
paddingVertical: 12,
},
});Open it from shared application code:
import { MagicModalHideReason, magicModal } from "magic-modal";
import { RatingModal, type RatingResult } from "./rating-modal";
export async function askForRating() {
const result = await magicModal.show<RatingResult>(RatingModal, {
accessibilityLabel: "Rate your experience",
swipeDirection: "down",
});
if (result.reason === MagicModalHideReason.INTENTIONAL_HIDE) {
return result.data.rating;
}
recordRatingCancellation(result.reason);
return undefined;
}A backdrop click or tap resolves with BACKDROP_PRESS. A completed downward swipe resolves with
SWIPE_COMPLETE. Web Escape and Android back resolve with BACK_BUTTON_PRESS.
Read the accessibility guide before shipping modal content with forms, destructive actions, or custom web markup.
Troubleshooting
Gesture Handler cannot find a root
Confirm that MagicModalPortal is a child of GestureHandlerRootView and that the root fills the
screen with style={{ flex: 1 }}.
Expo Web asks for browser dependencies
Install the browser runtime:
npx expo install react-dom react-native-web @expo/metro-runtimePeer versions drift after an Expo SDK upgrade
Run the install command for the affected target again. Expo selects a compatible set for the current SDK.
A scrollable modal dismisses while scrolling
Pass swipeDirection: undefined. Backdrop dismissal and Android back handling keep their default
behavior.