Shipnative
ShipnativeShipnative
Sign in
GuideSeptember 2026 · 9 min read

Expo Router Modals: Presentation, Sheets, and Dismissal

Two completely different things in a React Native app get called “a modal,” and picking the wrong one is where most of the pain comes from. One is a component that draws an overlay inside the screen you are already on. The other is a route that the navigator happens to present by sliding it up from the bottom. Expo Router gives you the second kind, and it behaves differently in every way that matters: it has a URL, it survives a deep link, the hardware back button closes it, and it unmounts on its own schedule. Here is how to pick a presentation mode, how the sheet variants actually behave on each platform, and how to keep a swipe-down from silently discarding a half-filled form.

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:

presentationWhat it looks likeScreen behindReach for it when
modalCard slides up, parent shrinks back on iOSDimmed, not interactiveCompose, edit, and settings destinations
formSheetPartial-height sheet with detentsVisible above the sheetShort forms, pickers, quick actions
transparentModalNo background of its ownFully visible — you draw the scrimCustom overlays, lightboxes, toasts-as-routes
fullScreenModalCovers everything, no parent peekHiddenOnboarding, camera, media viewers
containedModalModal rendered inside the RN view treeDimmedWhen a native modal breaks gestures or video
card (default)Standard push from the rightPushed off screenEverything 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:

  1. Detents are fractions, not pixels. A 0.4 detent 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.
  2. sheetExpandsWhenScrolledToEdge fights 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.
  3. Insets are not what you expect. A sheet does not reach the top of the screen, so useSafeAreaInsets().top is often 0 inside 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 — avoid

The 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.
  • transparentModal needs 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.

Frequently Asked Questions

What is the difference between an Expo Router modal and a React Native Modal component?

They solve different problems. The React Native Modal component renders an overlay inside the screen you are already on — it has no URL, no back-button history, and it unmounts with its parent. An Expo Router modal is a real route: it has its own file in the app directory, its own URL, its own place in the navigation stack, and the OS back gesture dismisses it. Use the route when the content is a destination (compose, edit, settings). Use the component when it is a transient overlay tied to one screen (a confirm dialog, an image lightbox).

How do I make a screen a modal in Expo Router?

Declare it in the parent Stack layout with a presentation option: <Stack.Screen name="new-post" options={{ presentation: "modal" }} />. The route still lives at app/new-post.tsx like any other screen — the only thing that changes is how the navigator presents it. Navigating with router.push("/new-post") then slides it up instead of pushing it sideways.

Why does my Expo Router modal cover the tab bar?

Because it is being presented by a stack that sits above the tabs — which is usually what you want. If the modal should appear inside a tab and keep the bar visible, the route has to be a child of that tab stack rather than a sibling of the tabs group. Where the file sits in the app directory decides which navigator presents it, and therefore what stays on screen behind it.

Does presentation: formSheet work on Android?

Modern react-native-screens versions do implement formSheet on Android, but it is not pixel-identical to iOS — detent behaviour, the grabber, and the corner radius all render differently, and older versions fall back to a full-height modal. Treat the sheet as a presentation hint rather than a guarantee, and check both platforms on device before you build layout that assumes a specific height.

How do I stop a user from swiping a modal away mid-form?

Set gestureEnabled: false in the screen options so the drag-to-dismiss gesture is disabled, and give the user an explicit close button that runs your own confirmation before calling router.back(). Disabling the gesture without providing that button leaves people trapped, so the two changes belong together.

Should I use router.back() or router.dismiss() to close a modal?

router.back() pops one entry of history, whatever it is. router.dismiss() specifically pops the modal stack, and router.dismissAll() closes every modal down to the screen underneath. If your modal contains its own inner stack — a two-step flow, say — back() only steps within it, while dismiss() closes the whole thing. Guard with router.canDismiss() when you are not sure a modal is on screen.

→

React Native Modal: The Component Version

When an in-screen overlay is the right tool, and which library to use for it.

Read guide →
→

Expo Router vs React Navigation

File-based routing versus explicit navigators — what you trade either way.

Compare →

Ship a real React Native app today

Describe, preview, and export Expo code — free to start.

Build with ShipNative →
ShipNative logoShipnative

Build mobile apps with AI. Describe, preview, and ship to iOS & Android in minutes.

Features

Text to App AIApp Generator from ScreenshotPRD to Mobile App

Tools

All free toolsApp Cost CalculatorApp Name GeneratorApp Store Keyword ToolReact Native Components

Blog

All blog postsHow to Build an App Without CodingBest AI Tools for Real Mobile AppsExpo EAS App Store ChecklistLovable, Cursor & v0 for MobileBest AI App Builders in 2026React Native AI App Builder Guide

Legal

FAQTerms of ServicePrivacy Policy

© 2026 ShipNative. All rights reserved.