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
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
overlayStyle: { ... }, // 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 },
close: { stiffness: 1000, damping: 500, mass: 3 },
},
}}Enable swipe-to-dismiss:
options={{
gestureEnabled: true,
gestureDirection: "vertical",
...Transition.Presets.SlideFromBottom(),
}}| Option | Description |
|---|---|
gestureEnabled |
Enable swipe-to-dismiss |
gestureDirection |
Direction(s) for swipe gesture |
gestureActivationArea |
Where gesture can start |
gestureResponseDistance |
Pixel threshold for activation |
gestureVelocityImpact |
How much velocity affects dismissal |
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
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
}The full screenStyleInterpolator receives these props:
| Prop | Description |
|---|---|
progress |
Combined progress (0-2) |
stackProgress |
Accumulated progress across entire 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. Pure JavaScript with native-level performance via react-native-screens.
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 { createComponentNavigator } from "react-native-screen-transitions/component-stack";
const Stack = createComponentNavigator();
<Stack.Navigator initialRoute="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