Web modals

Edit this page

Learn how to implement and customize the behavior of a modal in your web app using Expo Router.


Modern web apps require a flexible modal experience that adapts to different content sizes and user interactions. Expo Router provides various modal presentation patterns for modern web experiences. These patterns leverage presentation with modal, formSheet, transparentModal, or containedTransparentModal to present either a modal based on different screen widths, and provide customizable styling props using webModalStyle.

Get started

Modals in Expo Router are configured using Stack.Screen component with specific options. This requires the modal screen to be added to the layout file of your app's Stack.

Consider the following navigation tree, which includes a stack navigator defined in the layout file, a home screen where the modal is accessed, and the modal screen component:

app
_layout.tsx
index.tsx
modal.tsx

In the layout file (app/_layout.tsx), the modal screen component is added to the Stack navigator:

app/_layout.tsx
import { Stack } from 'expo-router'; export const unstable_settings = { anchor: 'index', }; export default function Layout() { return ( <Stack> <Stack.Screen name="index" /> <Stack.Screen name="modal" options={{ presentation: 'modal', // Enables modal behavior sheetAllowedDetents: [0.5, 1], // Array of snap positions for screens that have a width less than 786px. }} /> </Stack> ); }

The modal.tsx is used to display the contents of a modal:

app/modal.tsx
import { Text, View } from 'react-native'; export default function Modal() { return <View style={{ flex: 1, padding: 16 }}>{/* Modal content goes here */}</View>; }

Now, to open the modal from index.tsx, you can use router.push('/modal') in your index route:

app/index.tsx
import { router } from 'expo-router'; import { Pressable, Text, View, StyleSheet } from 'react-native'; export default function Home() { return ( <View style={styles.container}> <Text style={styles.title}>Home Screen</Text> <Pressable onPress={() => router.push('/modal')} style={styles.button}> <Text style={styles.buttonText}>Open Modal</Text> </Pressable> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', justifyContent: 'center', }, title: { fontSize: 24, fontWeight: 'bold', marginBottom: 20, }, button: { backgroundColor: '#007AFF', padding: 16, borderRadius: 8, }, buttonText: { color: 'white', fontSize: 16, fontWeight: '600', }, });

Here's the result of the above example:

Anchors and nested stacks

When working with stack or nested stack navigators, modals need to be properly anchored to ensure correct navigation behavior, especially when deep-linking to modal routes. Without anchoring, the screen behind the modal will be wiped away, leaving no navigation context.

An anchor serves as the base for the modal. In complex apps, when you have nested stacks, the anchor must be defined for the nested stack, and its value becomes the initial route of the stack.

You can configure an anchor by exporting unable_settings from your stack's layout file:

export const unstable_settings = { anchor: 'index', // Anchor to the index route };

In the above example, the anchor: index tells the Expo Router that it should maintain the specified anchor route in the background when presenting a modal.

Modal presentation style

The difference between the presentation of how a modal appears in your web app on a large screen (for example, a desktop) while maintaining the sheet behavior when the web app runs on a mobile device, depends on the configuration options. The following are options available for configuring a web modal's appearance that can be passed to the options object of a Stack.Screen.

OptionTypeDescription
presentation'modal', 'formSheet', 'transparentModal', 'containedTransparentModal'Modal presentation style. On screens with width more than 768px, all styles display as a centered overlay (for example, a lightbox).

On screens with width less than 768px, formSheet is used to display as a bottom sheet.

When set to transparentModal, it displays as an overlay without a completely obscure background content. Detents and sheet grabber properties are not applied. This presentation is useful when building your own custom modal.

Similar to transparentModal, when set to containedTransparentModal, it displays as an overlay without a completely obscure background content. Detents and other properties are not applied. This presentation is useful when building your own custom modal.
sheetAllowedDetentsnumber[], 'fitToContents'Snap positions as percentages (0.0-1.0) or automatic fitting. Only applies to screens with less than 768px width.
sheetGrabberVisiblebooleanOn iOS, shows/hides the drag handle at the top of the sheet. Not supported on Android and web. We recommend using a custom sheet header component to imitate the grabber across all platforms.
sheetCornerRadiusnumberCorner radius of the sheet in pixels.
webModalStyleWebModalStyleSpecial prop that allows web-specific styling options for fine-tuning modal appearance.

Custom modal styling with webModalStyle

Note: The webModalStyle properties only apply to web platforms. On mobile, the modal will automatically adapt to use sheet-like behavior for touch interaction.

You can use webModalStyle to customize the dimensions and appearance of your modals on web. It provides the following properties for further customization:

PropertyTypeDescriptionDefault
widthnumber stringOverride the width of the modal (px or percentage). Only applies for web platform on a desktop.-
heightnumber stringOverride the height of the modal (px or percentage). Only applies for web platform on a desktop.-
minHeightnumber stringMinimum height of the desktop modal (px or percentage). Overrides the default 640px clamp.640px
minWidthnumber stringMinimum width of the desktop modal (px or percentage). Overrides the default 580px.580px
borderstringOverride the border of the desktop modal (any valid CSS border value, for example, '1px solid #ccc' or 'none')None
overlayBackgroundstringOverride the overlay background color (any valid CSS color or rgba/hsla value).Semi-transparent black

Custom CSS properties

Expo Router uses custom CSS properties to style modals, which you can override globally using webModalStyle. These variables provide fine-grained control over a modal's appearance.

Width and height sizing variables

/* Default modal width (580px on desktop) */ --expo-router-modal-width: 580px; /* Maximum modal width (90vw by default) */ --expo-router-modal-max-width: 90vw; /* Minimum modal width (auto by default) */ --expo-router-modal-min-width: auto; /* Default modal height (640px) */ --expo-router-modal-height: 640px; /* Minimum modal height (follows height by default) */ --expo-router-modal-min-height: 640px;

Border and overlay styling variables

/* Modal border (1px solid rgba with transparency) */ --expo-router-modal-border: 1px solid rgba(61.2, 61.2, 66, 0.29); /* Modal corner radius (10px by default) */ --expo-router-modal-border-radius: 10px; /* Overlay background color (40% black by default) */ --expo-router-modal-overlay-background: rgba(0, 0, 0, 0.4);

How webModalStyle maps to CSS variables

When you use webModalStyle to override any of the sizing variables, Expo Router automatically sets these CSS variables to the values you provide:

// This webModalStyle configuration... webModalStyle: { width: 800, height: 600, border: '2px solid blue', overlayBackground: 'rgba(0, 0, 0, 0.7)', } // ...automatically sets these CSS variables: // --expo-router-modal-width: 800px // --expo-router-modal-height: 600px // --expo-router-modal-border: 2px solid blue // --expo-router-modal-overlay-background: rgba(0, 0, 0, 0.7)

Common examples

Full screen modal example

To create a full screen modal for content that covers maximum space, you can use webModalStyle property in your modal route's Stack.Screen options:

app/_layout.tsx
import { Stack } from 'expo-router'; export const unstable_settings = { anchor: 'index', }; export default function Layout() { return ( <Stack> <Stack.Screen name="index" /> <Stack.Screen name="modal" options={{ presentation: 'modal', webModalStyle: { width: '95vw', height: '95vh', border: 'none', }, }} /> </Stack> ); }

Here's the result of the above example:

When running your web app on mobile devices, you can set sheetAllowedDetents to fitToContents or a custom value if you want to avoid showing a full screen modal:

app/_layout.tsx
import { Stack } from 'expo-router'; export const unstable_settings = { anchor: 'index', }; export default function Layout() { return ( <Stack> <Stack.Screen name="index" /> <Stack.Screen name="modal" options={{ presentation: 'modal', webModalStyle: { width: '95vw', height: '95vh', border: 'none', }, sheetAllowedDetents: 'fitToContents', }} /> </Stack> ); }

The modal appears as a sheet on a mobile device:

Compact modal example

For smaller interactions, you can create a compact modal that fits its content:

app/_layout.tsx
import { Stack } from 'expo-router'; export const unstable_settings = { anchor: 'index', }; export default function Layout() { return ( <Stack> <Stack.Screen name="index" /> <Stack.Screen name="modal" options={{ presentation: 'modal', webModalStyle: { width: 400, height: 'auto', minHeight: 200, border: '1px solid #e5e7eb', overlayBackground: 'rgba(0, 0, 0, 0.3)', }, sheetCornerRadius: 12, sheetAllowedDetents: 'fitToContents', }} /> </Stack> ); }

Here's the result of the above example:

Transparent modal example

You can set the presentation option to transparentModal when you want to display an overlay that should maintain the visual context of the underlying screen:

app/_layout.tsx
import { Stack } from 'expo-router'; export const unstable_settings = { anchor: 'index', }; export default function Layout() { return ( <Stack> <Stack.Screen name="index" /> <Stack.Screen name="modal" options={{ presentation: 'transparentModal', }} /> </Stack> ); }

Here's the result of the above example:

Corner radius example

You customize the corner radius using sheetCornerRadius:

app/_layout.tsx
import { Stack } from 'expo-router'; export const unstable_settings = { anchor: 'index', }; export default function Layout() { return ( <Stack> <Stack.Screen name="index" /> <Stack.Screen name="modal" options={{ presentation: 'formSheet', sheetAllowedDetents: [0.4], sheetCornerRadius: 32, }} /> </Stack> ); }

Here's the result of the above example:

Custom detents example

You can use sheetAllowedDetents to define the height at which the modal can rest:

app/_layout.tsx
import { Stack } from 'expo-router'; export const unstable_settings = { anchor: 'index', }; export default function Layout() { return ( <Stack> <Stack.Screen name="index" /> <Stack.Screen name="modal" options={{ presentation: 'formSheet', sheetAllowedDetents: [0.2, 0.5, 0.8, 0.98], }} /> </Stack> ); }

Here's the result of the above example:

Global CSS customization

For your web app, if you are using a global CSS file in your project, you can also override width, height, border, and overlay variables.

You can add custom values using the --expo-router-* variables in your global CSS file:

/* Override default modal styling globally */ :root { --expo-router-modal-width: 700px; --expo-router-modal-min-width: auto; --expo-router-modal-max-width: 95vw; --expo-router-modal-height: 640px; --expo-router-modal-min-height: 640px; --expo-router-modal-border: 1px solid rgba(61.2, 61.2, 66, 0.29); --expo-router-modal-border-radius: 16px; --expo-router-modal-overlay-background: rgba(0, 0, 0, 0.5); }

Custom modal route implementation

The video above demonstrates a modal window that appears over the main content of the web page. The background dims to draw focus to the modal, which contains information for the user. This is typical behavior for web modals, where users can interact with the modal or close it to return to the main page.

You can achieve the above web modal behavior by using the transparentModal presentation mode, styling the overlay and modal content, and utilizing react-native-reanimated to animate the modal's presentation.

Modify your project's root layout (app/_layout.tsx) to add an options object to the modal route:

app/_layout.tsx
import { Stack } from 'expo-router'; export const unstable_settings = { initialRouteName: 'index', }; export default function Layout() { return ( <Stack> <Stack.Screen name="index" /> <Stack.Screen name="modal" options={{ presentation: 'transparentModal', animation: 'fade', headerShown: false, }} /> </Stack> ); }
Note: unstable_settings currently works only with Stack navigators.

The above example sets the index screen as the initialRouteName using unstable_settings. This ensures that the transparent modal is always rendered on top of the current screen, even when users navigate to the modal screen via a direct link.

Style the overlay and modal content in modal.tsx as shown below:

app/modal.tsx
import { Link } from 'expo-router'; import { Pressable, StyleSheet, Text } from 'react-native'; import Animated, { FadeIn, SlideInDown } from 'react-native-reanimated'; export default function Modal() { return ( <Animated.View entering={FadeIn} style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#00000040', }} > {/* Dismiss modal when pressing outside */} <Link href={'/'} asChild> <Pressable style={StyleSheet.absoluteFill} /> </Link> <Animated.View entering={SlideInDown} style={{ width: '90%', height: '80%', alignItems: 'center', justifyContent: 'center', backgroundColor: 'white', }} > <Text style={{ fontWeight: 'bold', marginBottom: 10 }}>Modal Screen</Text> <Link href="/"> <Text>← Go back</Text> </Link> </Animated.View> </Animated.View> ); }

You can customize the modal's appearance as per your needs.