Customizable screen transitions for React Native. Build gesture-driven, shared element, and fully custom animations with a simple API.
| iOS | Android |
|---|---|
ios.mp4 | android.mp4 |
- Full Animation Control β Define exactly how screens enter, exit, and respond to gestures
- Shared Elements β Smooth transitions between screens using the Bounds API
- Gesture Support β Swipe-to-dismiss with edge or full-screen activation
- Stack Progress β Track animation progress across the entire stack
- Ready-Made Presets β Instagram, Apple Music, X (Twitter) style transitions included
| Use Case | This Library | Alternative |
|---|---|---|
| Custom transitions (slide, zoom, fade variations) | Yes | @react-navigation/stack works too |
| Shared element transitions | Yes | Limited options elsewhere |
| Multi-stop sheets (bottom, top, side) with snap points | Yes | Dedicated sheet libraries |
| Gesture-driven animations (drag to dismiss, elastic) | Yes | Requires custom implementation |
| Instagram/Apple Music/Twitter-style transitions | Yes | Custom implementation |
| Simple push/pop with platform defaults | Overkill | @react-navigation/native-stack |
| Maximum raw performance on low-end devices | Not ideal | @react-navigation/native-stack |
Choose this library when you need custom animations, shared elements, or gesture-driven transitions that go beyond platform defaults.
Choose native-stack when you want platform-native transitions with zero configuration and maximum performance on low-end Android devices.
npm install react-native-screen-transitionsnpm install react-native-reanimated react-native-gesture-handler \ @react-navigation/native @react-navigation/native-stack \ @react-navigation/elements react-native-screens \ react-native-safe-area-contextimport { createBlankStackNavigator } from "react-native-screen-transitions/blank-stack"; import Transition from "react-native-screen-transitions"; const Stack = createBlankStackNavigator(); function App() { return ( <Stack.Navigator> <Stack.Screen name="Home" component={HomeScreen} /> <Stack.Screen name="Detail" component={DetailScreen} options={{ ...Transition.Presets.SlideFromBottom(), }} /> </Stack.Navigator> ); }import { withLayoutContext } from "expo-router"; import { createBlankStackNavigator, type BlankStackNavigationOptions, } from "react-native-screen-transitions/blank-stack"; const { Navigator } = createBlankStackNavigator(); export const Stack = withLayoutContext< BlankStackNavigationOptions, typeof Navigator >(Navigator);Use built-in presets for common transitions:
<Stack.Screen name="Detail" options={{ ...Transition.Presets.SlideFromBottom(), }} />| Preset | Description |
|---|---|
SlideFromTop() | Slides in from top |
SlideFromBottom() | Slides in from bottom (modal-style) |
ZoomIn() | Scales in with fade |
DraggableCard() | Multi-directional drag with scaling |
ElasticCard() | Elastic drag with overlay |
SharedIGImage({ sharedBoundTag }) | Instagram-style shared image |
SharedAppleMusic({ sharedBoundTag }) | Apple Music-style shared element |
SharedXImage({ sharedBoundTag }) | X (Twitter)-style image transition |
Every screen has a progress value that goes from 0 β 1 β 2:
0 βββββββββββ 1 βββββββββββ 2 entering visible exiting When navigating from A to B:
- Screen B: progress goes
0 β 1(entering) - Screen A: progress goes
1 β 2(exiting)
options={{ screenStyleInterpolator: ({ progress }) => { "worklet"; return { contentStyle: { opacity: interpolate(progress, [0, 1, 2], [0, 1, 0]), }, }; }, }}options={{ screenStyleInterpolator: ({ progress, layouts: { screen } }) => { "worklet"; return { contentStyle: { transform: [{ translateX: interpolate( progress, [0, 1, 2], [screen.width, 0, -screen.width * 0.3] ), }], }, }; }, }}options={{ screenStyleInterpolator: ({ progress, layouts: { screen } }) => { "worklet"; return { contentStyle: { transform: [{ translateY: interpolate(progress, [0, 1], [screen.height, 0]), }], }, }; }, }}Your interpolator can return:
return { contentStyle: { ... }, // Main screen backdropStyle: { ... }, // Semi-transparent backdrop ["my-id"]: { ... }, // Specific element via styleId };Control timing with spring configs:
options={{ screenStyleInterpolator: myInterpolator, transitionSpec: { open: { stiffness: 1000, damping: 500, mass: 3 }, // Screen enters close: { stiffness: 1000, damping: 500, mass: 3 }, // Screen exits expand: { stiffness: 300, damping: 30 }, // Snap point increases collapse: { stiffness: 300, damping: 30 }, // Snap point decreases }, }}Enable swipe-to-dismiss:
options={{ gestureEnabled: true, gestureDirection: "vertical", ...Transition.Presets.SlideFromBottom(), }}| Option | Description |
|---|---|
gestureEnabled | Enable swipe-to-dismiss (snap sheets: false blocks dismiss-to-0 only) |
gestureDirection | Direction(s) for swipe gesture |
gestureActivationArea | Where gesture can start |
gestureResponseDistance | Pixel threshold for activation |
gestureVelocityImpact | How much velocity affects dismissal (default: 0.3) |
gestureDrivesProgress | Whether gesture controls animation progress (default: true) |
snapVelocityImpact | How much velocity affects snap targeting (default: 0.1, lower = iOS-like) |
expandViaScrollView | Allow expansion from ScrollView at boundary (default: true) |
gestureSnapLocked | Lock gesture-based snap movement to current snap point |
backdropBehavior | Touch handling for backdrop area |
backdropComponent | Custom backdrop component (replaces default backdrop + press behavior) |
gestureDirection: "horizontal" // swipe left to dismiss gestureDirection: "horizontal-inverted" // swipe right to dismiss gestureDirection: "vertical" // swipe down to dismiss gestureDirection: "vertical-inverted" // swipe up to dismiss gestureDirection: "bidirectional" // any direction // Or combine multiple: gestureDirection: ["horizontal", "vertical"]// Simple - same for all edges gestureActivationArea: "edge" // only from screen edges gestureActivationArea: "screen" // anywhere on screen // Per-side configuration gestureActivationArea: { left: "edge", right: "screen", top: "edge", bottom: "screen", }Use transition-aware scrollables so gestures work correctly:
<Transition.ScrollView> {/* content */} </Transition.ScrollView> <Transition.FlatList data={items} renderItem={...} />Gesture rules with scrollables:
- vertical β only activates when scrolled to top
- vertical-inverted β only activates when scrolled to bottom
- horizontal β only activates at left/right scroll edges
Create multi-stop sheets that snap to defined positions. Works with any gesture direction (bottom sheets, top sheets, side sheets):
// Bottom sheet (most common) <Stack.Screen name="Sheet" options={{ gestureEnabled: true, gestureDirection: "vertical", snapPoints: [0.5, 1], // 50% and 100% of screen initialSnapIndex: 0, // Start at 50% backdropBehavior: "dismiss", // Tap backdrop to dismiss ...Transition.Presets.SlideFromBottom(), }} /> // Side sheet (same API, different direction) <Stack.Screen name="SidePanel" options={{ gestureEnabled: true, gestureDirection: "horizontal", snapPoints: [0.3, 0.7, 1], // 30%, 70%, 100% of screen width initialSnapIndex: 1, // Add a horizontal screenStyleInterpolator for drawer-style motion }} />| Option | Description |
|---|---|
snapPoints | Array of fractions (0-1) where sheet can rest |
initialSnapIndex | Index of initial snap point (default: 0) |
gestureSnapLocked | Locks gesture snapping to current point (programmatic snapTo still works) |
backdropBehavior | Touch handling: "block", "passthrough", "dismiss", "collapse" |
backdropComponent | Custom backdrop component; replaces default backdrop + tap handling |
| Value | Description |
|---|---|
"block" | Backdrop catches all touches (default) |
"passthrough" | Touches pass through to content behind |
"dismiss" | Tapping backdrop dismisses the screen |
"collapse" | Tapping backdrop collapses to next lower snap point, then dismisses |
Use backdropComponent when you want full control over backdrop visuals and interactions.
- When provided, it replaces the default backdrop entirely (including default tap behavior)
- You are responsible for dismiss/collapse actions inside the custom component
backdropBehaviorstill controls container-level pointer event behavior
import { router } from "expo-router"; import { Pressable } from "react-native"; import Animated, { interpolate, useAnimatedStyle } from "react-native-reanimated"; import { useScreenAnimation } from "react-native-screen-transitions"; function SheetBackdrop() { const animation = useScreenAnimation(); const style = useAnimatedStyle(() => ({ opacity: interpolate(animation.value.current.progress, [0, 1], [0, 0.4]), backgroundColor: "#000", })); return ( <Pressable style={{ flex: 1 }} onPress={() => router.back()}> <Animated.View style={[{ flex: 1 }, style]} /> </Pressable> ); } <Stack.Screen name="Sheet" options={{ snapPoints: [0.5, 1], backdropBehavior: "dismiss", backdropComponent: SheetBackdrop, }} />Control snap points from anywhere in your app:
import { snapTo } from "react-native-screen-transitions"; function BottomSheet() { // Expand to full height (index 1) const expand = () => snapTo(1); // Collapse to half height (index 0) const collapse = () => snapTo(0); return ( <View> <Button title="Expand" onPress={expand} /> <Button title="Collapse" onPress={collapse} /> </View> ); }The animated snapIndex is available in screen interpolators via ScreenInterpolationProps:
screenStyleInterpolator: ({ snapIndex }) => { // snapIndex interpolates between snap point indices // e.g., 0.5 means halfway between snap point 0 and 1 return { contentStyle: { opacity: interpolate(snapIndex, [0, 1], [0.5, 1]), }, }; }With Transition.ScrollView inside a snap-enabled sheet:
expandViaScrollView: true: At boundary, swipe up expands and swipe down collapses (or dismisses at min if enabled)expandViaScrollView: false: Expand works only via deadspace; collapse/dismiss via scroll still works at boundary- Scrolled into content: Normal scroll behavior
Customize snap animations separately from enter/exit:
transitionSpec: { open: { stiffness: 1000, damping: 500, mass: 3 }, // Screen enter close: { stiffness: 1000, damping: 500, mass: 3 }, // Screen exit expand: { stiffness: 300, damping: 30 }, // Snap up collapse: { stiffness: 300, damping: 30 }, // Snap down }Animate elements between screens by tagging them.
<Transition.Pressable sharedBoundTag="avatar" onPress={() => navigation.navigate("Profile")} > <Image source={avatar} style={{ width: 50, height: 50 }} /> </Transition.Pressable><Transition.View sharedBoundTag="avatar"> <Image source={avatar} style={{ width: 200, height: 200 }} /> </Transition.View>screenStyleInterpolator: ({ bounds }) => { "worklet"; return { avatar: bounds({ id: "avatar", method: "transform" }), }; };| Option | Values | Description |
|---|---|---|
id | string | The sharedBoundTag to match |
method | "transform" "size" "content" | How to animate |
space | "relative" "absolute" | Coordinate space |
scaleMode | "match" "none" "uniform" | Aspect ratio handling |
raw | boolean | Return raw values |
Persistent UI that animates with the stack:
const TabBar = ({ focusedIndex, progress }) => { const style = useAnimatedStyle(() => ({ transform: [{ translateY: interpolate(progress.value, [0, 1], [100, 0]) }], })); return <Animated.View style={[styles.tabBar, style]} />; }; <Stack.Screen name="Home" options={{ overlay: TabBar, overlayShown: true, }} />| Prop | Description |
|---|---|
focusedRoute | Currently focused route |
focusedIndex | Index of focused screen |
routes | All routes in the stack |
progress | Stack progress (derived value) |
navigation | Navigation prop |
meta | Custom metadata from options |
| Component | Description |
|---|---|
Transition.View | Animated view with sharedBoundTag |
Transition.Pressable | Pressable that measures bounds |
Transition.ScrollView | ScrollView with gesture coordination |
Transition.FlatList | FlatList with gesture coordination |
Transition.MaskedView | For reveal effects (requires native) |
Access animation state inside a screen:
import { useScreenAnimation } from "react-native-screen-transitions"; function DetailScreen() { const animation = useScreenAnimation(); const style = useAnimatedStyle(() => ({ opacity: animation.value.current.progress, })); return <Animated.View style={style}>...</Animated.View>; }Get navigation state without animation values:
import { useScreenState } from "react-native-screen-transitions"; function DetailScreen() { const { index, focusedRoute, routes, navigation } = useScreenState(); // ... }Access navigation history across the app:
import { useHistory } from "react-native-screen-transitions"; function MyComponent() { const { getRecent, getPath } = useHistory(); const recentScreens = getRecent(5); // Last 5 screens const path = getPath(fromKey, toKey); // Path between screens }Coordinate your own pan gestures with the navigation gesture:
import { useScreenGesture } from "react-native-screen-transitions"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; function MyScreen() { const screenGesture = useScreenGesture(); const myPanGesture = Gesture.Pan() .simultaneousWithExternalGesture(screenGesture) .onUpdate((e) => { // Your gesture logic }); return ( <GestureDetector gesture={myPanGesture}> <View /> </GestureDetector> ); }Use this when you have custom pan gestures that need to work alongside screen dismiss gestures.
The full screenStyleInterpolator receives these props:
| Prop | Description |
|---|---|
progress | Combined progress (0-2) |
stackProgress | Accumulated progress across entire stack |
snapIndex | Animated snap point index (-1 if no snap points) |
focused | Whether this screen is the topmost in the stack |
current | Current screen state |
previous | Previous screen state |
next | Next screen state |
active | Screen driving the transition |
inactive | Screen NOT driving the transition |
layouts.screen | Screen dimensions |
insets | Safe area insets |
bounds | Shared element bounds function |
Each screen state (current, previous, next, active, inactive) contains:
| Property | Description |
|---|---|
progress | Animation progress (0 or 1) |
closing | Whether closing (0 or 1) |
entering | Whether entering (0 or 1) |
animating | Whether animating (0 or 1) |
gesture | Gesture values (x, y, normalized values) |
meta | Custom metadata from options |
Pass custom data between screens:
// Screen A options={{ meta: { hideTabBar: true } }} // Screen B reads it screenStyleInterpolator: (props) => { "worklet"; const hideTabBar = props.inactive?.meta?.hideTabBar; // ... };Use styleId to target specific elements:
// In options screenStyleInterpolator: ({ progress }) => { "worklet"; return { "hero-image": { opacity: interpolate(progress, [0, 1], [0, 1]), }, }; }; // In component <Transition.View styleId="hero-image"> <Image source={...} /> </Transition.View>All three stacks share the same animation API. Choose based on your needs:
| Stack | Best For |
|---|---|
| Blank Stack | Most apps. Full control, all features. |
| Native Stack | When you need native screen primitives. |
| Component Stack | Embedded flows, isolated from React Navigation. (Experimental) |
The default choice. Uses react-native-screens for native screen containers, with animations powered by Reanimated worklets running on the UI thread (not the JS thread).
import { createBlankStackNavigator } from "react-native-screen-transitions/blank-stack";Extends @react-navigation/native-stack. Requires enableTransitions: true.
import { createNativeStackNavigator } from "react-native-screen-transitions/native-stack"; <Stack.Screen name="Detail" options={{ enableTransitions: true, ...Transition.Presets.SlideFromBottom(), }} />Note: This API is experimental and may change based on community feedback.
Standalone navigator, not connected to React Navigation. Ideal for embedded flows.
import { createComponentStackNavigator } from "react-native-screen-transitions/component-stack"; const Stack = createComponentStackNavigator(); <Stack.Navigator initialRouteName="step1"> <Stack.Screen name="step1" component={Step1} /> <Stack.Screen name="step2" component={Step2} /> </Stack.Navigator>The Native Stack uses transparent modal presentation to intercept transitions. This has trade-offs:
- Delayed touch events β Exiting screens may have briefly delayed touch response
- beforeRemove listeners β Relies on navigation lifecycle events
- Rapid navigation β Some edge cases with very fast navigation sequences
For most apps, Blank Stack avoids these issues entirely.
- No deep linking β Routes aren't part of your URL structure
- Isolated state β Doesn't affect parent navigation
- Touch pass-through β Uses
pointerEvents="box-none"by default
Force maximum refresh rate during transitions (for 90Hz/120Hz displays):
options={{ experimental_enableHighRefreshRate: true, }}Required for SharedIGImage and SharedAppleMusic presets. The masked view creates the "reveal" effect where content expands from the shared element.
Note: Requires native code. Will not work in Expo Go.
# Expo npx expo install @react-native-masked-view/masked-view # Bare React Native npm install @react-native-masked-view/masked-view cd ios && pod install1. Source Screen β Tag pressable elements:
// app/index.tsx import { router } from "expo-router"; import { View } from "react-native"; import Transition from "react-native-screen-transitions"; export default function HomeScreen() { return ( <View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}> <Transition.Pressable sharedBoundTag="album-art" style={{ width: 200, height: 200, backgroundColor: "#1DB954", borderRadius: 12, }} onPress={() => { router.push({ pathname: "/details", params: { sharedBoundTag: "album-art" }, }); }} /> </View> ); }2. Destination Screen β Wrap with MaskedView and match the tag:
// app/details.tsx import { useLocalSearchParams } from "expo-router"; import Transition from "react-native-screen-transitions"; export default function DetailsScreen() { const { sharedBoundTag } = useLocalSearchParams<{ sharedBoundTag: string }>(); return ( <Transition.MaskedView style={{ flex: 1, backgroundColor: "#121212" }}> <Transition.View sharedBoundTag={sharedBoundTag} style={{ backgroundColor: "#1DB954", width: 400, height: 400, alignSelf: "center", borderRadius: 12, }} /> {/* Additional screen content */} </Transition.MaskedView> ); }3. Layout β Apply the preset with dynamic tag:
// app/_layout.tsx import Transition from "react-native-screen-transitions"; import { Stack } from "./stack"; export default function RootLayout() { return ( <Stack> <Stack.Screen name="index" /> <Stack.Screen name="details" options={({ route }) => ({ ...Transition.Presets.SharedAppleMusic({ sharedBoundTag: route.params?.sharedBoundTag ?? "", }), })} /> </Stack> ); }Transition.Pressablemeasures its bounds on press and stores them with the tagTransition.Viewon the destination registers as the target for that tagTransition.MaskedViewclips content to the animating shared element bounds- The preset interpolates position, size, and mask for a seamless expand/collapse effect
This package is developed in my spare time.
If you'd like to fuel the next release, buy me a coffee
MIT