Route or component? Decide this first
The question is not “does it slide up from the bottom.” It is would a user reasonably expect to link to this, or press back out of it?
- A route, if it is a destination: composing a post, editing a profile, an item detail you might share a link to, a checkout step. It needs a URL and a history entry.
- A component,if it is a decoration on the current screen: a “delete this?” confirm, a tooltip, an image lightbox. Nobody deep-links to a confirm dialog.
Getting this wrong is expensive later. A confirm dialog built as a route pollutes the back stack and shows up in analytics as a screen view. A compose screen built as a component loses its state the moment the parent re-renders, cannot be deep-linked from a push notification, and needs you to hand-roll the back handling that the navigator would have given you free. The component-side guide covers the other half of this decision.
The presentation modes, compared
presentation is a screen option on the native stack. These are the values worth knowing:
| presentation | What it looks like | Screen behind | Reach for it when |
|---|---|---|---|
modal | Card slides up, parent shrinks back on iOS | Dimmed, not interactive | Compose, edit, and settings destinations |
formSheet | Partial-height sheet with detents | Visible above the sheet | Short forms, pickers, quick actions |
transparentModal | No background of its own | Fully visible — you draw the scrim | Custom overlays, lightboxes, toasts-as-routes |
fullScreenModal | Covers everything, no parent peek | Hidden | Onboarding, camera, media viewers |
containedModal | Modal rendered inside the RN view tree | Dimmed | When a native modal breaks gestures or video |
card (default) | Standard push from the right | Pushed off screen | Everything that is not a modal |
iOS renders these as genuine UIKit presentations, so they inherit real system behaviour — the parent card scaling back, the interactive drag-to-dismiss, the rubber-banding at the top of a sheet. Android maps them onto its own conventions, which is why the same option can look noticeably different across the two. Screenshot both before you commit to a design.
The minimal working setup
Two files. The route itself is completely ordinary — nothing in it knows it is a modal. Only the layout decides that:
app/
_layout.tsx <- declares the presentation
(tabs)/
_layout.tsx
index.tsx
profile.tsx
new-post.tsx <- the modal route, a sibling of (tabs)// app/_layout.tsx
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="new-post"
options={{ presentation: 'modal', title: 'New Post' }}
/>
</Stack>
);
}// anywhere inside the tabs
import { router, Link } from 'expo-router';
<Link href="/new-post">New post</Link>
// or imperatively:
router.push('/new-post');Note where new-post.tsx sits. It is a sibling of the tabs group, so the root stack presents it and the modal covers the tab bar. Move that file to app/(tabs)/new-post.tsxand it becomes a tab route instead — which is the actual answer to “why is my modal inside the tab bar” and to its opposite. File position determines the presenting navigator. Nothing else does.
Sheets: detents and the height trap
formSheet gives you a partial-height sheet with system drag behaviour, no third-party library involved:
<Stack.Screen
name="filter"
options={{
presentation: 'formSheet',
sheetAllowedDetents: [0.4, 0.9], // fractions of screen height
sheetGrabberVisible: true,
sheetCornerRadius: 24,
sheetExpandsWhenScrolledToEdge: false,
}}
/>Three things that bite here:
- Detents are fractions, not pixels. A
0.4detent is 40% of a very different number on a small phone than on a tablet. If the content has a fixed intrinsic height, measure it and derive the fraction rather than hardcoding one that only looks right on your device. sheetExpandsWhenScrolledToEdgefights your scroll view. Left on, dragging inside a list near its top expands the sheet instead of scrolling. That is correct iOS behaviour and users hate it inside a long form. Turn it off when the sheet contains a scrollable list.- Insets are not what you expect. A sheet does not reach the top of the screen, so
useSafeAreaInsets().topis often0inside it while the bottom inset still applies. Padding a sheet with the top inset produces a mystery gap on some devices and none on others — see the safe-area guide for why.
If you need a sheet that is not a route — one that opens over the current screen and keeps its scroll position — a bottom-sheet library is still the better fit. formSheet is for sheets that are destinations.
Closing it: back, dismiss, or dismissAll
These are not synonyms, and the difference only shows up once your modal contains more than one screen:
router.back(); // pop one history entry, modal or not
router.dismiss(); // pop the modal stack by one
router.dismissAll(); // close every modal, back to the screen underneath
router.canDismiss(); // false when nothing is presented — guard with this
router.replace('/'); // leaves the modal presented on some stacks — avoidThe classic bug: a modal with a two-step flow (pick item, then confirm). On the confirm step, router.back()returns to step one instead of closing, so a “Done” button wired to back() appears to do nothing. dismissAll()is what “Done” means.
The other one: calling dismiss() on a screen that is not actually presented modally — after a deep link, for instance, where the user landed on the route directly and there is nothing underneath it. Guard with router.canDismiss() ? router.dismiss() : router.replace('/') so a notification tap does not leave someone stranded on a screen with a dead close button.
Protecting a half-filled form
Drag-to-dismiss is a gift right up until the user has typed 200 words. The fix is two changes that have to ship together — disable the gesture and give an explicit way out:
import { useEffect, useState } from 'react';
import { Alert } from 'react-native';
import { router, useNavigation } from 'expo-router';
export default function NewPost() {
const [body, setBody] = useState('');
const navigation = useNavigation();
const dirty = body.length > 0;
useEffect(() => {
navigation.setOptions({ gestureEnabled: !dirty });
}, [dirty, navigation]);
const close = () => {
if (!dirty) return router.dismiss();
Alert.alert('Discard this post?', 'Your draft will be lost.', [
{ text: 'Keep editing', style: 'cancel' },
{ text: 'Discard', style: 'destructive', onPress: () => router.dismiss() },
]);
};
// ... render a header close button wired to close()
}Disabling gestureEnabled without adding that button is how you ship a screen nobody can leave — and on iOS, where the swipe is the muscle-memory exit, people will try it three times before they look for a button. Test the trapped case deliberately.
Four things that go wrong on Android
- The parent does not shrink back. That scale-down of the screen behind is an iOS presentation detail. If your design depends on seeing it, the Android build will look flat by comparison — and there is nothing to configure, it is simply a different platform convention.
- Hardware back closes the modal. Which is correct, and also means your confirm-before-discard logic has to cover it, not just the close button. The gesture flag does not intercept the hardware button.
transparentModalneeds its own scrim. There is no dimming by default — you render the semi-transparent background yourself, including a pressable that dismisses when tapped outside your content.- The keyboard shifts the sheet. A form inside a sheet gets pushed by the soft keyboard differently than a full screen does. Wire it up properly rather than adding fixed padding — the keyboard-avoiding guide covers the behaviour differences.
None of these are bugs to fix — they are the two platforms disagreeing. Decide per-modal whether you match each platform’s convention or force one design onto both, and be deliberate about it rather than discovering the difference in a store review screenshot.
Skip the layout wiring
Modal routes are the kind of thing that is five minutes of work once you have written them twice, and an afternoon of confusion the first time — because the mistake is usually a file in the wrong folder, not a wrong option. If you would rather start from a project where the tab group, the root stack, and the modal routes are already wired correctly, describe your app at shipnative.dev and it generates a real Expo Router project — navigation structure included — that you can run on your phone and then edit like any other codebase.