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:
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:
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:
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
.
Option | Type | Description |
---|---|---|
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. |
sheetAllowedDetents | number[] , 'fitToContents' | Snap positions as percentages (0.0-1.0) or automatic fitting. Only applies to screens with less than 768px width. |
sheetGrabberVisible | boolean | On 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. |
sheetCornerRadius | number | Corner radius of the sheet in pixels. |
webModalStyle | WebModalStyle | Special prop that allows web-specific styling options for fine-tuning modal appearance. |
Custom modal styling with webModalStyle
Note: ThewebModalStyle
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:
Property | Type | Description | Default |
---|---|---|---|
width | number string | Override the width of the modal (px or percentage). Only applies for web platform on a desktop. | - |
height | number string | Override the height of the modal (px or percentage). Only applies for web platform on a desktop. | - |
minHeight | number string | Minimum height of the desktop modal (px or percentage). Overrides the default 640px clamp. | 640px |
minWidth | number string | Minimum width of the desktop modal (px or percentage). Overrides the default 580px. | 580px |
border | string | Override the border of the desktop modal (any valid CSS border value, for example, '1px solid #ccc' or 'none') | None |
overlayBackground | string | Override 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:
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:
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:
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:
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
:
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:
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:
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 withStack
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:
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.