| iOS | Android |
|---|---|
sreens-ios.mp4 |
screen-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.
This library is inspired by the transition system in @react-navigation/stack (not the native stack). If you're familiar with how transitions work there (e.g., using interpolators), you'll find this similar.
- Predefined animation presets (e.g., SlideFromTop, ZoomIn, DraggableCard).
- Gesture support for interactive transitions (e.g., drag-to-dismiss).
- Animations using Reanimated.
- Easy integration with expo-router and react-navigation.
- 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-handlerIn expo-router, you can define transitions in your root layout (app/_layout.tsx) using the listeners prop on Stack.Screen. Wrap your app in GestureHandlerRootView for gesture support.
// app/_layout.tsx
import { Stack } from "expo-router";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import Transition from "react-native-screen-transitions";
export default function RootLayout() {
return (
<GestureHandlerRootView>
<Stack>
<Stack.Screen
name="index"
listeners={Transition.createConfig}
/>
<Stack.Screen
name="a"
options={Transition.defaultScreenOptions()}
listeners={(l) =>
Transition.createConfig({
...l,
...Transition.presets.SlideFromTop(),
})
}
/>
{/* Add more screens with presets */}
</Stack>
</GestureHandlerRootView>
);
}Note: Transition.defaultScreenOptions() returns the required screen options for animations to work properly. It sets presentation: "containedTransparentModal", headerShown: false, and animation: "none" to ensure the library can control the transition animations.
For grouped routes with layouts (e.g., app/group-a/_layout.tsx), wrap the nested Stack in Transition.View to enable transitions:
// app/group-a/_layout.tsx
import { Stack } from "expo-router";
import Transition from "react-native-screen-transitions";
export default function GroupLayout() {
return (
<Transition.View>
<Stack>
<Stack.Screen name="a" />
{/* Nested screens */}
</Stack>
</Transition.View>
);
}For react-navigation, use createNativeStackNavigator and apply transitions via listeners and options.
// App.tsx
import { createStaticNavigation } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import Transition from "react-native-screen-transitions";
import { Home } from "./screens/Home"; // Your screens
import { ScreenA } from "./screens/ScreenA";
const RootStack = createNativeStackNavigator({
screens: {
Home: {
screen: Home,
listeners: Transition.createConfig,
},
ScreenA: {
screen: ScreenA,
options: Transition.defaultScreenOptions(),
listeners: (l) =>
Transition.createConfig({
...l,
...Transition.presets.SlideFromTop(),
}),
},
// Add more screens
},
});
const Navigation = createStaticNavigation(RootStack);
export default function App() {
return (
<GestureHandlerRootView>
<Navigation />
</GestureHandlerRootView>
);
}For nested navigators, wrap them in Transition.View similar to expo-router.
Use these out-of-the-box animations via Transition.presets:
SlideFromTop(): Screen slides in from the top.ZoomIn(): Screen zooms in from the center.SlideFromBottom(): Screen slides in from the bottom.DraggableCard(): Interactive card-like drag gesture.ElasticCard(): Elastic bounce effect on drag.
Example:
listeners={(l) => Transition.createConfig({ ...l, ...Transition.presets.DraggableCard() })}There are two ways to define custom animations: at the navigator level using screenStyleInterpolator (recommended for animating both screens simultaneously), or at the screen level using useScreenAnimation hook.
Define a screenStyleInterpolator at the navigator level to animate both the entering and exiting screens simultaneously. This approach provides the most control.
// app/_layout.tsx
import { Stack } from "expo-router";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import Transition from "react-native-screen-transitions";
import { interpolate, Easing } from "react-native-reanimated";
export default function RootLayout() {
return (
<GestureHandlerRootView>
<Stack>
<Stack.Screen
name="a"
listeners={Transition.createConfig} // Add blank config so system knows what to animate
/>
<Stack.Screen
name="b"
options={Transition.defaultScreenOptions()}
listeners={(l) =>
Transition.createConfig({
...l,
gestureDirection: "horizontal",
gestureEnabled: true,
gestureResponseDistance: 50,
gestureVelocityImpact: 0.3,
screenStyleInterpolator: ({
current,
next,
layouts: { screen: { width } },
}) => {
"worklet";
const progress = current.progress.value + (next?.progress.value || 0);
const translateX = interpolate(
progress,
[0, 1, 2],
[width, 0, width * -0.3],
"clamp"
);
return {
contentStyle: {
transform: [{ translateX }],
},
};
},
transitionSpec: {
open: {
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
duration: 1000,
},
close: {
damping: 10,
mass: 0.5,
stiffness: 100,
},
},
})
}
/>
</Stack>
</GestureHandlerRootView>
);
}When using screenStyleInterpolator, both screens must wrap their content in Transition.View:
// a.tsx
import Transition from 'react-native-screen-transitions';
export default function A() {
return (
<Transition.View>
{/* Your content */}
</Transition.View>
);
}
// b.tsx
import Transition from 'react-native-screen-transitions';
export default function B() {
return (
<Transition.View>
{/* Your content */}
</Transition.View>
);
}Alternatively, define animations at the screen level using the useScreenAnimation hook. This is useful for screen-specific effects or when you don't need to animate both screens.
// app/_layout.tsx
<Stack.Screen
name="a"
listeners={Transition.createConfig}
/>
<Stack.Screen
name="b"
options={Transition.defaultScreenOptions()}
listeners={(l) =>
Transition.createConfig({
...l,
transitionSpec: {
open: {
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
duration: 1000,
},
close: {
damping: 10,
mass: 0.5,
stiffness: 100,
},
},
})
}
/>// a.tsx (previous screen)
import { useScreenAnimation } from 'react-native-screen-transitions';
import Animated, { useAnimatedStyle, interpolate } from 'react-native-reanimated';
export default function A() {
const { next, layouts: { screen: { width } } } = useScreenAnimation();
const animatedStyle = useAnimatedStyle(() => {
// Unfocusing animation - screen slides left when next screen enters
const translateX = interpolate(next?.progress.value || 0, [0, 1], [0, width * -0.3]);
return {
transform: [{ translateX }],
};
});
return (
<Animated.View style={animatedStyle}>
{/* Your content */}
</Animated.View>
);
}
// b.tsx (entering screen)
import { useScreenAnimation } from 'react-native-screen-transitions';
import Animated, { useAnimatedStyle, interpolate } from 'react-native-reanimated';
export default function B() {
const { current, layouts: { screen: { width } } } = useScreenAnimation();
const animatedStyle = useAnimatedStyle(() => {
// Focusing animation - screen slides in from right
const translateX = interpolate(current.progress.value, [0, 1], [width, 0]);
return {
transform: [{ translateX }],
};
});
return (
<Animated.View style={[{ flex: 1 }, animatedStyle]}>
{/* Your content */}
</Animated.View>
);
}
This is a WIP package, so expect improvements. Current known issues:
- Gestures with ScrollViews: Gestures can be wonky when combined with scrollable content. For example, if a screen defines vertical dismissal gestures and contains a vertical
ScrollView, the gesture may not trigger reliably (conflicts with scroll handling). - Gestures Dismisall with Nested Navigators: When using nested navigators with gesture dismissal enabled, dismissing a nested screen via gesture may cause the transparent modal to appear dismissed while remaining open. This affects the visual state but not the actual navigation state.
- Web Support: Not intended or tested for web—focus is on mobile (iOS/Android). Web may have issues with gestures and animations.
This package is something I'll work on in my freetime. However, you can see the roadmap I plan on following in the coming future.
Roadmap:
- Fix gesture conflicts with ScrollViews (e.g., better gesture priority handling).
- Add more presets and customization options.
- Improve documentation and examples.
- Potential web support if demand arises.
- Testing for more edge cases (e.g., modals, tabs).
See the examples in /examples/expo-router-example and /examples/react-nav-example for full demos.
MIT