Four options, one question
| Option | Looks like | Android back | Deep-linkable | Use it for |
|---|---|---|---|---|
| Expo Router modal route | Platform card / sheet | Works automatically | Yes — it has a URL | Compose, filters, detail, settings — anything screen-shaped |
| Core <Modal> component | Overlay you style yourself | Only via onRequestClose | No | Confirm dialogs, lightboxes, blocking loaders |
| Bottom sheet library | Draggable sheet, snap points | You wire it | No | Half-open states, map details, players |
| Alert.alert | True system dialog | Native behaviour | No | Destructive confirms — two lines of code |
The question that decides it: could a user arrive here from a link or a notification?If yes — a post, an order, a compose screen — it’s a route, and presenting it modally is a styling choice on top of real navigation. If no — “delete this, are you sure?” — it’s an overlay, and making it a route just adds history entries the user has to back out of.
The good default: a modal route
In an Expo Router app, a modal is a normal screen with one extra prop in the layout. You get the platform presentation, swipe-to-dismiss on iOS, working hardware back on Android, and a URL — all without writing any of it:
// app/_layout.tsx
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="compose"
options={{
presentation: 'modal', // slides up, card on iOS
title: 'New post',
}}
/>
</Stack>
);
}// app/compose.tsx
import { useState } from 'react';
import { Button, TextInput, View } from 'react-native';
import { router, useNavigation } from 'expo-router';
export default function ComposeScreen() {
const [text, setText] = useState('');
const close = () => {
if (router.canGoBack()) router.back();
else router.replace('/'); // opened via deep link: nothing to go back to
};
return (
<View style={{ flex: 1, padding: 16, gap: 12 }}>
<TextInput
style={{ flex: 1, color: '#fff', fontSize: 16, textAlignVertical: 'top' }}
placeholder="What's happening?"
placeholderTextColor="#8b8b8b"
multiline
autoFocus
value={text}
onChangeText={setText}
/>
<Button title="Post" onPress={async () => { await api.post(text); close(); }} />
</View>
);
}The canGoBack() check is the part worth copying. A modal opened from a push notification or a shared link has no history behind it, so a bare router.back()leaves the user stuck on a screen they can’t leave. On iOS you can also set gestureEnabled: falsefor a screen with unsaved input, so a stray swipe can’t discard a half-written post — but if you do that, make the Cancel button obvious.
The overlay case: the core Modal component
For a confirm dialog or a lightbox — something that isn’t a place in the app — the built-in component is right. It renders above everything, and you style it entirely yourself:
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
export function ConfirmDialog({ visible, title, body, onConfirm, onCancel }) {
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onCancel} // Android hardware back — never omit this
statusBarTranslucent
>
{/* tapping the scrim dismisses */}
<Pressable style={styles.scrim} onPress={onCancel}>
{/* stop the press from reaching the scrim */}
<Pressable style={styles.card} onPress={(e) => e.stopPropagation()}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.body}>{body}</Text>
<View style={styles.actions}>
<Pressable onPress={onCancel}><Text style={styles.cancel}>Cancel</Text></Pressable>
<Pressable onPress={onConfirm}><Text style={styles.destructive}>Delete</Text></Pressable>
</View>
</Pressable>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create({
scrim: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', padding: 24 },
card: { backgroundColor: '#16161a', borderRadius: 16, padding: 20, gap: 10 },
title: { color: '#fff', fontSize: 18, fontWeight: '700' },
body: { color: 'rgba(255,255,255,0.7)', fontSize: 15, lineHeight: 21 },
actions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 20, marginTop: 8 },
cancel: { color: 'rgba(255,255,255,0.7)', fontWeight: '600' },
destructive: { color: '#ff5c5c', fontWeight: '600' },
});Worth saying plainly: for exactly this dialog, Alert.alert() is two lines, uses the real system dialog, handles back and accessibility for free, and looks correct on both platforms without you maintaining it. Write the custom one only when the design genuinely needs to differ.
The traps
- No
onRequestClose. Android’s back button does nothing and the user force-quits your app. It’s invisible during iOS development, which is exactly why it ships. - Two core modals at once. Stacking iOS
Modalinstances is a long-standing source of blank screens and undismissable views. Close the first in the completion callback before opening the second, or move to navigation-based modals where the stack manages presentation. - Keyboard avoidance placed outside. A modal is a separate view hierarchy — a
KeyboardAvoidingViewwrapping the screen behind it has no effect. Put it inside the modal content. The form validation guide covers the keyboard story in full. - Modals for everything.Three modals deep and the user has no idea where they are or how to get back. If the content has its own content, it’s a screen.
- Ignoring the safe area. A full-screen modal draws under the notch and the home indicator. Use
useSafeAreaInsets()inside the modal, not just on the screens beneath it.
If you find yourself adding a drag handle and snap points to a core Modal, stop — you’re rebuilding a bottom sheet, and that’s a solved problem.
The shortcut: describe the flow, not the presentation
Which modal to use is a five-second decision once you know the rule, and thirty minutes of the wrong behaviour once you don’t. The wiring — the route, the presentation option, the dismissal path, the back button — is identical in every app that has ever had a compose screen.
Describe the flow in ShipNative — “tapping New Post opens a compose screen that slides up, with Cancel and Post in the header” — and it sets up the Expo Router modal route, the dismissal handling and the safe-area padding, then runs it on your phone so you can test the swipe-down and the Android back button where they actually behave differently. Export the full Expo project whenever you want it.
Build it free
Describe your app in one sentence and have it running on your own phone in minutes at shipnative.dev. No credit card, full React Native source export whenever you want it.