With Expo Router, how do you present a streaming app's plan-picker route as a modal or a draggable sheet instead of a pushed screen?
answer
- set on the layout, not the file
- Stack.Screen options.presentation
- iOS swipe down, Android back
- formSheet plus sheetAllowedDetents
- Android: at most three detents
basics
~10 sDeclare the route in its stack layout with a Stack.Screen whose options set presentation: 'modal', or presentation: 'formSheet' plus sheetAllowedDetents such as [0.5, 1] for a sheet that rests at half and full height.
solid answer
~40 sThe route file stays an ordinary screen; the **stack layout** decides how it appears. In `_layout.tsx` I add `<Stack.Screen name="plans" options={{ presentation: 'modal' }} />`, so pushing `/plans` slides it up on iOS, where a swipe down dismisses it, and shows it on top on Android, where the back button dismisses it. For a sheet I use `presentation: 'formSheet'` with `sheetAllowedDetents` (default `[1.0]`), for example `[0.5, 1]`, and `sheetInitialDetentIndex` (default `0`). Android honours at most three detents, `sheetGrabberVisible` is iOS-only, and `'fitToContents'` needs explicitly sized content instead of `flex: 1`. Unlike the core `Modal` component, a modal route has a URL and lives in navigation history.
code
tsx · 20 lines// src/app/_layout.tsx
import { Stack } from 'expo-router';
export default function Layout() {
return (
<Stack>
<Stack.Screen name="index" />
<Stack.Screen name="plans" options={{ presentation: 'modal' }} />
<Stack.Screen
name="episode/[id]"
options={{
presentation: 'formSheet',
sheetAllowedDetents: [0.5, 1],
sheetInitialDetentIndex: 0,
sheetGrabberVisible: true,
}}
/>
</Stack>
);
}go deeper
Recall where the setting lives: a Stack.Screen in the layout with options.presentation set to 'modal' or 'formSheet'.
Explain the platform dismissal difference, the detent options and their defaults, and when a modal route beats the core Modal component.
Anticipate the traps: Android's three-detent limit, fitToContents needing sized content, iOS-only options, and a deep-linked modal that needs an anchor behind it.
Decide which flows deserve a URL: modal routes are linkable and restorable, so treat them as navigation surface area with the testing and analytics that implies.
## Two ways to show something modally An Expo app has two different tools that both look like "a modal": | Tool | What it is | Use it for | |---|---|---| | React Native `Modal` component | A view rendered over the current screen, outside navigation | Confirmations, short dialogs | | A **modal route** in Expo Router | A file route presented modally by a stack | Flows with their own URL, like a plan picker | A modal route is a real screen: it has a path such as `/plans`, it can be opened with a `Link` or the `router` object, and a link can open it directly. That is what a streaming app's "Choose your plan" flow needs. ## Making a route modal The file itself, `src/app/plans.tsx`, is an ordinary screen component. What makes it modal is the **layout that declares it**. In the stack's `_layout.tsx`, you add a `Stack.Screen` for the route and set its `presentation` option: ```tsx <Stack> <Stack.Screen name="index" /> <Stack.Screen name="plans" options={{ presentation: 'modal' }} /> </Stack> ``` Pushing `/plans` now presents it modally instead of sliding it in as a card. The platforms differ: - **iOS**: the screen slides up from the bottom and is dismissed by swiping it down. - **Android**: the screen appears on top of the current one and is dismissed with the system back button. - **Web**: by default the route renders as its own page, so the screen should offer its own close control; Expo also has an alpha web-modal mode behind an environment variable. ## `presentation` values The `presentation` option comes from the native stack that Expo Router's `Stack` wraps: - `card` — the default push onto the stack. - `modal` — presented modally; it can host a nested stack of its own. - `transparentModal` — modal, with the previous screen still visible behind a translucent background. - `containedModal` and `containedTransparentModal` — iOS "current context" styles; Android falls back to `modal` and `transparentModal`. - `fullScreenModal` — iOS full-screen modal style; Android falls back to `modal`. - `formSheet` — a **bottom sheet** that rests at configurable heights. ## `formSheet` and detents A **detent** is a height where a sheet can rest. For a streaming app's "episode details" sheet that opens half-height and can be dragged to full screen, set `presentation: 'formSheet'` and configure: 1. `sheetAllowedDetents` — an ascending array of fractions of the screen height, such as `[0.5, 1]`, or the literal `'fitToContents'`. The default is `[1.0]`, a single full-height detent. 2. `sheetInitialDetentIndex` — the index the sheet opens at; the default is `0`, the smallest detent, and `'last'` is allowed. 3. `sheetGrabberVisible` — shows the grabber handle; **iOS only**. 4. `sheetCornerRadius` and `sheetLargestUndimmedDetentIndex` — the corner radius, and the largest detent at which the screen behind stays undimmed. Platform limits to remember: - **Android honours at most three detents**; extra values are ignored. iOS accepts any number. - **The array must be ascending**; development builds raise an error otherwise. - **`'fitToContents'` needs explicitly sized content.** The sheet measures its content to choose its height, so a root `View` with `flex: 1` has nothing to measure. With numeric detents, `flex: 1` fills the sheet (on iOS, since SDK 55). ## Choosing between them - A transient question ("Remove this title from Downloads?") belongs in the core `Modal` or an alert: it has no URL and no reason to be in history. - A flow a user might be sent to from outside the app, like the plan picker or the episode details sheet, belongs in a modal route, so it has a path and survives a reload on web. - A partial-height sheet with drag handles is `formSheet`; a full-cover takeover is `modal` or `fullScreenModal`. When a modal route can be opened directly by a link, give its stack an **anchor** so a screen sits behind it; that is a separate setting on the layout. Closing a modal from code uses the `router` object's back and dismiss calls. These option names and defaults are those of Expo SDK 57 (Expo Router 57.x on React Native 0.86), whose `Stack` wraps React Navigation 7's native stack.
- When would you use React Native's core Modal instead of an Expo Router modal route?For a transient interaction that has no reason to be in navigation: a confirmation such as removing a title from downloads, or a short dialog. The core `Modal` renders over the current screen without a URL or history entry. A flow a user might be linked to, or that should survive a web reload, such as a plan picker, belongs in a modal route.
- A formSheet uses sheetAllowedDetents: 'fitToContents' and appears with zero height. Why?With `'fitToContents'` the sheet sizes itself by measuring its content. If the screen's root view uses `flex: 1`, it has no intrinsic height to measure, so the sheet has nothing to fit. Give the content an explicit size, or switch to numeric detents such as `[0.5, 1]`, where `flex: 1` fills the sheet.
A modal route is a shop with its own address in the mall directory, so someone can send you straight to it; the core Modal is a notice held up in the corridor for a moment, with no address at all.
saying these in an interview costs you the question
- Presentation is set by exporting options from the modal file
- A modal route has no URL, just like the core Modal
- formSheet accepts any number of detents on Android
- sheetGrabberVisible shows a grabber on both platforms
- fitToContents works with a flex: 1 root view
- The default sheetAllowedDetents is [0.5, 1]