Magic Modallatest
Platforms

Expo

Set up Magic Modal for Expo Web, iOS, Android, or a shared project.

Add Magic Modal once:

pnpm add magic-modal

Expo 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-runtime

Use the shared application root. Start the browser target:

npx expo start --web

This 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-screens

Use the shared application root. Build the iOS target:

npx expo run:ios

The 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-screens

Use the shared application root. Build the Android target:

npx expo run:android

Android'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:

app/_layout.tsx
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:

components/rating-modal.tsx
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-runtime

Peer 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.

On this page