| iOS | Android |
|---|---|
ios.mp4 |
android.mp4 |
WIP: This package is a work-in-progress. It provides customizable screen transition animations for React Native apps, primarily designed for use with expo-router and react-navigation. It supports gestures, predefined presets, and custom animations, making it easy to add polished transitions to your navigation flows.
- Platforms: Currently tested on iOS and Android. Not tested or intended for webโweb support is not a priority and may not work due to gesture and animation differences.
- Dependencies: Requires React Native, Reanimated, Gesture Handler, and either expo-router or react-navigation.
npm install react-native-screen-transitions
# or
yarn add react-native-screen-transitions
# or
bun add 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 type {
ParamListBase,
StackNavigationState,
} from "@react-navigation/native";
import { withLayoutContext } from "expo-router";
import {
createNativeStackNavigator,
type NativeStackNavigationEventMap,
type NativeStackNavigationOptions,
} from "react-native-screen-transitions";
const { Navigator } = createNativeStackNavigator();
export const Stack = withLayoutContext<
NativeStackNavigationOptions,
typeof Navigator,
StackNavigationState<ParamListBase>,
NativeStackNavigationEventMap
>(Navigator);Thatโs it โ youโre ready to go.
If youโre using React Navigation directly (not Expo Router), the navigator is already configured. No extra setup is requiredโjust import and use as usual:
import { createNativeStackNavigator } from 'react-native-screen-transitions';
const Stack = createNativeStackNavigator();
// Use Stack.Navigator and Stack.Screen as normalThis package ships an extended native stack built on top of React Navigationโs native stack. All the usual native-stack options are available, plus the following extras:
| Option | Type | Description |
|---|---|---|
enableTransitions |
boolean |
Switches the screen to a transparent modal and disables the header so custom transitions can take over. |
screenStyleInterpolator |
ScreenStyleInterpolator |
Function that returns animated styles based on transition progress. |
transitionSpec |
TransitionSpec |
Reanimated timing/spring config for open/close animations. |
gestureEnabled |
boolean |
Whether swipe-to-dismiss is allowed. |
gestureDirection |
GestureDirection | GestureDirection[] |
Allowed swipe directions (vertical, horizontal, etc.). |
gestureVelocityImpact |
number |
How much the gestureโs velocity affects dismissal. |
gestureResponseDistance |
number |
Distance from screen where the gesture is recognized. |
gestureDrivesProgress |
boolean |
Whether the gesture directly drives the transition progress. |
To avoid collisions with the new options above, the built-in React Navigation gesture props are renamed:
| React Navigation prop | Renamed to |
|---|---|
gestureDirection |
nativeGestureDirection |
gestureEnabled |
nativeGestureEnabled |
gestureResponseDistance |
nativeGestureResponseDistance |
All other React Navigation native-stack options keep their original names.
Pick a built-in preset and spread it into the screenโs options. The incoming screen automatically controls the previous screen.
<Stack>
<Stack.Screen
name="a"
/>
<Stack.Screen
name="b"
options={{
...Transition.presets.SlideFromTop(),
}}
/>
<Stack.Screen
name="c"
options={{
...Transition.presets.SlideFromBottom(),
}}
/>
</Stack>Instead of presets, you can define a custom transition directly on the screenโs options.
screenStyleInterpolator receives an object with the following useful fields:
progressโ overall transition progress (0 โ 2).currentโ state for the current screen (includesprogress,closing,gesture,route, etc.).previousโ state for the previous screen (may beundefined).nextโ state for the next screen (may beundefined).layouts.screenโ{ width, height }of the container.insetsโ{ top, right, bottom, left }safe-area insets.bounds(id)โ helper to compute shared-element transforms (see IntelliSense for chainable methods).activeBoundIdโ id of the active bound.focusedโ state of the current screen
import { interpolate } from 'react-native-reanimated'
<Stack.Screen
name="b"
options={{
enableTransitions: true,
screenStyleInterpolator: ({
layouts: { screen: { width } },
progress,
}) => {
"worklet";
const x = interpolate(progress, [0, 1, 2], [width, 0, -width]);
return {
contentStyle: {
transform: [{ translateX: x }],
},
};
},
transitionSpec: {
close: Transition.specs.DefaultSpec,
open: Transition.specs.DefaultSpec,
},
}}
/>In this example the incoming screen slides in from the right while the exiting screen slides out to the left.
For per-screen control, import the useScreenAnimation hook and compose your own animated styles.
import { useScreenAnimation } from 'react-native-screen-transitions';
import Animated, { useAnimatedStyle, interpolate } from 'react-native-reanimated';
export default function BScreen() {
const props = useScreenAnimation();
const animatedStyle = useAnimatedStyle(() => {
const { current: { progress } } = props.value
return {
opacity: progress
};
});
return (
<Animated.View style={[{ flex: 1 }, animatedStyle]}>
{/* Your content */}
</Animated.View>
);
}You can drag a screen away even when it contains a scroll view. Just swap the regular scrollable for a transition-aware one:
import Transition from 'react-native-screen-transitions';
import { LegendList } from "@legendapp/list"
import { FlashList } from "@shopify/flash-list";
// Drop-in replacements
const ScrollView = Transition.ScrollView;
const FlatList = Transition.FlatList;
// Or wrap any list you like
const TransitionFlashList =
Transition.createTransitionAwareComponent(FlashList, { isScrollable: true });
const TransitionLegendList =
Transition.createTransitionAwareComponent(LegendList, { isScrollable: true} );Enable the gesture on the screen:
<Stack.Screen
name="gallery"
options={{
enableTransitions: true,
gestureEnabled: true,
gestureDirection: 'vertical', // or 'horizontal', ['vertical', 'horizontal'], etc.
}}
/>Use it in the screen:
export default function B() {
return (
<Transition.ScrollView>
{/* content */}
</Transition.ScrollView>
);
}Gesture rules (handled automatically):
- vertical โ only starts when the list is at the very top
- vertical-inverted โ only starts when the list is at the very bottom
- horizontal / horizontal-inverted โ only starts when the list is at the left or right edge
These rules apply only when the screen contains a scrollable. If no scroll view is present, the gesture can begin from anywhere on the screenโnot restricted to the edges.
Bounds let you animate any component between two screens by measuring its start and end positions.
They are not shared elementsโtheyโre just measurements.
Tag the component you want to animate with sharedBoundTag, then describe how it should move when the screen transition starts.
- Tag the source component
<Transition.View sharedBoundTag="hero" style={{ width: 100, height: 100 }}>
<Image source={uri} style={{ width: '100%', height: '100%' }} resizeMode="cover" />
</Transition.View>- Tag the destination component (same id)
<Transition.View sharedBoundTag="hero" style={{ width: 200, height: 200 }}>
<Image source={uri} style={{ width: '100%', height: '100%' }} resizeMode="cover" />
</Transition.View>- Drive the animation in
screenStyleInterpolator
screenStyleInterpolator: ({
activeBoundId,
bounds,
focused,
current,
next,
}) => {
"worklet";
const animatedBoundStyles = bounds()
.relative()
.transform()
.build();
return {
[activeBoundId]: animatedBoundStyles,
};
};Thatโs itโthe bounds helper works alongside focused and unfocused screens.
For further customization, separate logic by the focused prop:
screenStyleInterpolator: ({
activeBoundId,
bounds,
focused,
current,
next,
}) => {
"worklet";
if (focused) {
const focusedBoundStyles = bounds()
.relative()
.transform()
.build();
return {
[activeBoundId]: focusedBoundStyles,
};
}
return {}
};| Modifier | When to use |
|---|---|
gestures({x,y}) |
Sync the bound with live gesture deltas (drag, swipe). |
toFullscreen() |
Destination has no sharedBoundTag; animate to full-screen size. |
absolute() |
Element is not constrained by parent layout (uses pageX/pageY). |
relative() |
Element is inside layout constraints (default). |
transform() |
Animate with translateX/Y + scaleX/Y (default). |
size() |
Animate translateX/Y + width/height (no scale). |
content() |
Center the container so its bound aligns with the source at progress start. |
contentFill() / contentFit() |
Control how the content scales inside the container. |
build() |
Finalize the animated style object. |
Need the raw measurements or styles for a specific bound?
Call bounds.get(boundId, phase) to retrieve the exact dimensions and style object for any bound tag and screen phase (current, next, or previous).
const heroMetrics = bounds.get('hero', 'current');
// heroMetrics = { bounds: { x, y, width, height, pageX, pageY }, styles: { ... } }Use this when you want explicit control over which boundโs data you animate, regardless of the current screen focus.
Use styleId to animate a single view inside a screen.
- Tag the element:
<Transition.View styleId="fade-box" style={{ width: 100, height: 100, backgroundColor: 'crimson' }} />- Drive it from the interpolator:
screenStyleInterpolator: ({ progress }) => {
"worklet";
return {
'fade-box': {
opacity: interpolate(progress, [0, 1, 2],[0, 1, 0])
}
};
};The red square fades in as the screen opens.
- Delayed Touch Events โ Thereโs a noticeable delay in touch events, likely caused by the
beforeRemovelistener in the native stack. If this affects your app, please hold off on using this package until a fix is available. - Deeply nested navigators with scrollables โ Behavior is currently unstable. We recommend using programmatic dismissal for deeply nested navigators that contain scrollables, as the gesture-driven dismissal logic needs an overhaul.
This package is provided as-is and is developed in my free time. While I strive to maintain and improve it, please understand that:
- Updates and bug fixes may take time to implement
- Feature requests will be considered but may not be prioritized immediately
I apologize for any inconvenience this may cause. If you encounter issues or have suggestions, please feel free to open an issue on the repository.
Iโve estimated I downed around 60 cups of coffee while building this. If youโd like to fuel the next release, buy me a coffee
MIT