Transitions and Presentation
Card against modal, the native stack animation names, form sheets, and which dismissal gestures exist on which platform.
<Stack.Screen name="compose" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />A stack screen is configured by its options, and two of them decide how it arrives. presentation chooses the kind of screen; animation chooses the movement. The native stack runs these on the platform's own navigation controller, so they look like the rest of the operating system rather than like a web transition. There is no CSS here: no transition property, no keyframes, no easing string.
Presentation
presentation accepts card (the default push), modal, fullScreenModal, transparentModal, containedModal, containedTransparentModal and formSheet. A card is part of the flow and gets a back control. A modal interrupts the flow and should get a close control. transparentModal leaves the screen underneath visible, so the dim background is yours to draw.
Animation
animation accepts default, fade, fade_from_bottom, flip, simple_push, slide_from_bottom, slide_from_right, slide_from_left and none. animationTypeForReplace (push or pop) covers the case where a screen replaces another, which is what makes a sign-out feel like going back rather than forward.
Gestures are not symmetrical
gestureEnabled, gestureDirection and fullScreenGestureEnabled are iOS only. An iOS modal can be swiped down; the same modal on Android cannot, so a screen with no visible close button is a dead end there.
One documented shorthand: gestureDirection: 'vertical' expands to animation: 'slide_from_bottom', fullScreenGestureEnabled: true and animationMatchesGesture: true. The two forms are the same thing.
Example
// app/_layout.tsx
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
// screenOptions sets the default for every screen in this stack.
// A screen's own options override it.
<Stack screenOptions={{ animation: 'slide_from_right' }}>
{/* Default presentation: a card, pushed in, with a back control. */}
<Stack.Screen name="index" options={{ title: 'Trips' }} />
{/* A modal covers the screen and reads as an interruption.
On iOS it can be swiped down. On Android it cannot, which is
why this screen draws its own Close button. */}
<Stack.Screen
name="new-trip"
options={{ presentation: 'modal', title: 'New trip' }}
/>
{/* transparentModal keeps the screen underneath visible.
The dim layer is drawn by the screen itself, not by the
navigator, and there is no z-index involved: later siblings
in the tree paint on top. */}
<Stack.Screen
name="share"
options={{
presentation: 'transparentModal',
animation: 'fade',
headerShown: false,
}}
/>
{/* Replacing this screen should animate backwards, so signing
out feels like leaving rather than advancing. */}
<Stack.Screen
name="sign-in"
options={{ animationTypeForReplace: 'pop', headerShown: false }}
/>
</Stack>
);
}When to use it
- Present a compose or create screen as a modal so it reads as an interruption rather than a step forward.
- Show a filter panel as a form sheet that leaves the results visible behind it.
- Animate a sign-out replacement backwards so it does not feel like progress.
- Respect the system Reduce Motion setting across every screen transition at once.
More examples
A form sheet with two heights
A form sheet is a partial-height screen that leaves context visible behind it, which suits filters, a picker or a short form. Because it is a real screen rather than an overlay component, it gets a route, a URL and the back behaviour of the stack. Use sheetAllowedDetents: 'fitToContents' instead of fractions when the content height decides.
// app/(browse)/_layout.tsx
import { Stack } from 'expo-router';
export default function BrowseLayout() {
return (
<Stack>
<Stack.Screen name="results" options={{ title: 'Results' }} />
<Stack.Screen
name="filters"
options={{
presentation: 'formSheet',
// Fractions of the screen height, sorted ascending.
// Android supports at most three detents.
sheetAllowedDetents: [0.4, 0.9],
// The drag handle at the top of the sheet: iOS only.
sheetGrabberVisible: true,
headerShown: false,
}}
/>
</Stack>
);
}The gestureDirection shorthand, written both ways
Worth recognising in someone else's code: a single gestureDirection line can be the reason a screen slides up from the bottom and can be dragged anywhere on the screen to dismiss. Pulling the options out into typed constants also gets you autocomplete for the allowed string values instead of guessing them.
// app/compose/_layout.tsx
import { Stack } from 'expo-router';
import type { NativeStackNavigationOptions } from '@react-navigation/native-stack';
// Shorthand (iOS): one option that implies three.
const shorthand: NativeStackNavigationOptions = {
gestureDirection: 'vertical',
};
// Longhand: exactly what the shorthand expands to.
const longhand: NativeStackNavigationOptions = {
animation: 'slide_from_bottom',
fullScreenGestureEnabled: true,
animationMatchesGesture: true,
};
export default function ComposeLayout() {
return (
<Stack>
{/* These two screens behave identically on iOS. */}
<Stack.Screen name="reply" options={shorthand} />
<Stack.Screen name="forward" options={longhand} />
</Stack>
);
}Turning animations off for accessibility
Reduce Motion is a system setting on both platforms, and screen transitions are exactly the kind of movement it is meant to suppress. Because screenOptions is read on every render, flipping the state is enough; no navigator needs rebuilding. The listener returns a subscription, so remove it in the cleanup.
// app/_layout.tsx
import { useEffect, useState } from 'react';
import { AccessibilityInfo } from 'react-native';
import { Stack } from 'expo-router';
export default function RootLayout() {
const [reduceMotion, setReduceMotion] = useState(false);
useEffect(() => {
// Read the current setting, then follow later changes.
AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion);
const subscription = AccessibilityInfo.addEventListener(
'reduceMotionChanged',
setReduceMotion,
);
return () => subscription.remove();
}, []);
return (
<Stack
screenOptions={{
// 'none' removes the transition entirely; 'fade' is a gentler
// middle ground than a slide for motion-sensitive users.
animation: reduceMotion ? 'none' : 'slide_from_right',
}}
/>
);
}
Discussion