React Native Screen Transition with Expo Router
By Native Animation · Updated
A React Native screen transition should preserve navigation meaning: opening a detail route creates history, Back returns through it, and a direct URL still renders a useful screen. This guide uses Expo Router for a real two-screen stack. It then explains where a local shared-element animation fits. It does not introduce a universal transition library or suggest that animating two views creates a navigation stack.
Start with the SDK 58 navigation surface
This example targets Expo SDK 58 and uses its versioned Router Stack reference. The Expo documentation index, stack guide, and navigation guide describe the surrounding setup. Read the SDK version labels when following a current guide: newer options may require a later SDK.
For React Native navigation animation, start with Stack's default presentation and change only what the task needs. The navigator owns route lifecycle and native history. Reanimated is used here to read Reduce Motion; it does not drive a second transform over the navigator's transition. A stack transition, an in-screen layout animation, and a shared-element flight solve different problems.
Use an existing Expo Router application. If adding routing to an older project, follow the manual installation guide in full, including its entry point and app configuration. Installing the package alone is not that migration. Resolve SDK-compatible dependencies with:
npx expo install expo-router react-native-safe-area-context react-native-screens expo-linking expo-constants expo-status-bar react-native-reanimated react-native-workletsUse bunx in Bun projects. No changes to the Native Animation catalog app's own entry point are needed to read this tutorial. The following three files are a small Router app, not files to paste into a component's source folder.
Define the stack and motion policy
Create src/app/_layout.tsx. These are the only two screens in this minimal example; helper components belong outside src/app. The default native header supplies the usual navigation affordance and keeps screen content below it. Your host still owns bottom safe-area spacing for controls near the home indicator.
import { Stack } from 'expo-router';
import { useReducedMotion } from 'react-native-reanimated';
export default function Layout() {
const reduced = useReducedMotion();
return (
<Stack screenOptions={{ animation: reduced ? 'none' : 'default' }}>
<Stack.Screen name="index" options={{ title: 'Trails' }} />
<Stack.Screen name="details" options={{ title: 'Trail details' }} />
</Stack>
);
}The startup Reduce Motion preference selects no route animation. Keep route changes functional even when every visual transition is removed. If your app must react to changes while open, subscribe to AccessibilityInfo reduceMotionChanged and update the navigator option from that state. Do not delay a route action until a decorative spring finishes.
Open the detail route
Create src/app/index.tsx. Link supplies the destination; asChild passes activation to a Pressable with a minimum forty-eight-point target. It remains a link on web instead of becoming an unrelated click handler. The route receives a small stable ID, not a serialized artwork object or a copy of your entire record.
import { Link } from 'expo-router';
import { Pressable, Text, View } from 'react-native';
export default function Trails() {
return (
<View style={{ flex: 1, padding: 24, backgroundColor: '#FFFFFF' }}>
<Text accessibilityRole="header">Choose a trail</Text>
<Link href={{ pathname: '/details', params: { id: 'ridge' } }} asChild>
<Pressable accessibilityRole="link" accessibilityLabel="Open Ridge Trail details"
style={{ minHeight: 48, paddingVertical: 16 }}><Text>Ridge Trail · View details</Text></Pressable>
</Link>
</View>
);
}Handle a direct entry and Back
Create src/app/details.tsx. Query parameters are external input, so normalize array values and validate the ID. This example has one local record; a real app should render loading, missing, and error states while its data layer resolves the same identifier.
import { router, useLocalSearchParams } from 'expo-router';
import { Pressable, Text, View } from 'react-native';
export default function Details() {
const { id } = useLocalSearchParams<{ id?: string | string[] }>();
const selected = Array.isArray(id) ? id[0] : id;
const known = selected === 'ridge';
return (
<View style={{ flex: 1, padding: 24, gap: 16, backgroundColor: '#FFFFFF' }}>
<Text accessibilityRole="header">{known ? 'Ridge Trail' : 'Trail unavailable'}</Text>
<Text>{known ? 'A 3-mile loop with a shaded overlook.' : 'Choose a trail from the list.'}</Text>
<Pressable accessibilityRole="button" accessibilityLabel="Back to trails"
onPress={() => { if (router.canGoBack()) router.back(); else router.replace('/'); }}
style={{ minHeight: 48, paddingVertical: 16 }}><Text>Back to trails</Text></Pressable>
</View>
);
}A direct visit to /details?id=ridge may have no previous stack entry. The explicit Back to trails control checks canGoBack and replaces with the list when necessary. It also provides a destination for the unknown-ID state. Native header Back, the Android system action, and browser history retain their navigator/platform behavior; the example does not override the device Back button with a global handler.
Set expectations for platform transitions
| Concern | Responsibility |
|---|---|
| Push, pop, history, route parameters | Expo Router Stack |
| Default native transition | Navigator and platform |
| Reduce Motion | Disable route animation through the stack option |
| Browser navigation | Real links and history; no promise of matching the native animation |
| Artwork moving between local list/detail views | A separate, measured visual component |
Keep the default animation until you have a reason to change it. Stack offers options such as fade and slide transitions, but support, gesture matching, and duration customization depend on the platform and transition. In particular, a universal millisecond duration is not implied by animationDuration. Consult the stack options for your installed SDK before adding it. The current navigation transitions guide describes an experimental React transition mode for SDK 58 and later; do not copy that API into an SDK 57 app. It coordinates React rendering rather than supplying a universal shared-element animation. This guide uses neither that API nor a sharedTransitionTag recipe.
Preserve stable route IDs, pre-size artwork to prevent layout shifts, and keep long JS work out of the press handler. A screenshot flying between rectangles needs measured source and destination bounds, an interruption policy, and a fallback when either view is absent. An animated overlay must not leave both screens simultaneously interactive or hide a loading error behind an endless transition.
What Routed Shared Element actually supplies
Routed Shared Element is a local list/detail continuity component. Its host passes item data and controlled route state; both branches live inside one component. It supplies measured artwork flight, reverse motion, focus restoration, and a fade fallback for direct entry or a missing origin. It does not register Expo Router routes, own deep links, install a native stack, or provide cross-navigator shared elements.
Use the three-file tutorial for actual navigation. Choose the catalog piece for an embedded list/detail experience when that local route model fits. Connecting the piece to URL state is host integration work: retain one authoritative route, handle external navigation requests, and avoid running two independent transition systems over the same artwork. Do not assume its local route state can be passed directly to Expo Router.
Each published piece is reviewed on physical iPhone and Android devices with Expo Go before release. Explore onboarding examples, onboarding animation, animated tabs, and in-app overlays for adjacent presentation patterns.
FAQ
Is this a React Native navigation animation library?
No. The tutorial uses Expo Router's stack. The catalog piece supplies one local list/detail animation, with a narrower API than a navigation framework.
Why does opening a detail URL skip the shared-element flight?
A direct visit has no mounted source rectangle to animate from. Render the destination immediately or fade it in; missing geometry is a normal entry path, not an error to conceal.
Will web and native transitions look identical?
This example promises usable routes and controls on each platform. It does not promise identical browser and native animation, gesture behavior, or system Back presentation.