rn-screen-transitions
React Native Screen Transitions — Best Practices Guide
react-native-screen-transitionsfor React Native & Expo Router — custom animations, gestures, shared elements, and snap points.
Installation
npm install react-native-screen-transitions
Required peer dependencies:
npm 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-context
For shared element presets (SharedIGImage, SharedAppleMusic, SharedXImage):
# Expo
npx expo install @react-native-masked-view/masked-view
# Bare RN
npm install @react-native-masked-view/masked-view && cd ios && pod install
MaskedView requires native code — it will not work in Expo Go.
Stack Types
All three stacks share the same animation API. Choose based on your needs:
| Stack | Import | Best For |
|---|---|---|
| Blank Stack (recommended) | react-native-screen-transitions/blank-stack |
Most apps — full control, all features |
| Native Stack | react-native-screen-transitions/native-stack |
When you need native screen primitives (requires enableTransitions: true) |
| Component Stack (experimental) | react-native-screen-transitions/component-stack |
Embedded flows isolated from React Navigation |
Quick Setup
// React Navigation
import { createBlankStackNavigator } from "react-native-screen-transitions/blank-stack";
const Stack = createBlankStackNavigator();
// Expo Router — create a reusable stack component
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);
Understanding Progress
Every screen has a progress value: 0 → 1 → 2
0 ─────────── 1 ─────────── 2
entering visible exiting
When navigating A → B:
- Screen B: progress
0 → 1(entering) - Screen A: progress
1 → 2(being pushed behind)
This is the foundation of all custom animations — your screenStyleInterpolator maps this progress to visual styles.
Presets
Use built-in presets for common transitions without writing custom interpolators:
import Transition from "react-native-screen-transitions";
<Stack.Screen
name="Detail"
options={{
...Transition.Presets.SlideFromBottom(),
}}
/>
| Preset | Description | Use Case |
|---|---|---|
SlideFromBottom() |
Slides from bottom | Standard modals |
SlideFromTop() |
Slides from top | Dropdown modals |
ZoomIn() |
Scales in with fade | Alert dialogs |
DraggableCard() |
Multi-directional drag with scaling | Cards |
ElasticCard() |
Elastic drag with overlay | Dismissible cards |
SharedIGImage({ sharedBoundTag }) |
Instagram-style shared image | Photo galleries |
SharedAppleMusic({ sharedBoundTag }) |
Apple Music-style shared element | Music apps |
SharedXImage({ sharedBoundTag }) |
X (Twitter)-style image transition | Social apps |
Dynamic tags (when the tag comes from route params):
<Stack.Screen
name="details"
options={({ route }) => ({
...Transition.Presets.SharedAppleMusic({
sharedBoundTag: route.params?.sharedBoundTag ?? "",
}),
})}
/>
Custom Animations
screenStyleInterpolator
The core animation API. Receives progress, layout dimensions, and focus state — returns styles.
options={{
screenStyleInterpolator: ({
progress,
layouts: { screen },
focused,
bounds,
}) => {
"worklet"; // REQUIRED — runs on UI thread
return {
contentStyle: { /* main screen styles */ },
backdropStyle: { /* backdrop overlay styles */ },
// ["my-id"]: { ... } — target specific elements via styleId
};
},
}}
The "worklet" directive is mandatory in every interpolator. Without it, animations run on the JS thread and will jank.
Common Custom Transitions
Fade:
screenStyleInterpolator: ({ progress }) => {
"worklet";
return {
contentStyle: {
opacity: interpolate(progress, [0, 1, 2], [0, 1, 0]),
},
};
},
Slide from right with background scale:
screenStyleInterpolator: ({
progress,
layouts: { screen: { width } },
focused,
}) => {
"worklet";
if (focused) {
return {
contentStyle: {
transform: [{
translateX: interpolate(progress, [0, 1, 2], [width, 0, -width * 0.3]),
}],
},
};
}
// Background screen scales down
return {
contentStyle: {
transform: [{ scale: interpolate(progress, [1, 2], [1, 0.95]) }],
},
};
},
Slide from bottom:
screenStyleInterpolator: ({ progress, layouts: { screen } }) => {
"worklet";
return {
contentStyle: {
transform: [{
translateY: interpolate(progress, [0, 1], [screen.height, 0]),
}],
},
};
},
Animation Specs (Spring Config)
Control timing with spring physics:
transitionSpec: {
open: { stiffness: 1000, damping: 500, mass: 3 },
close: { stiffness: 1000, damping: 500, mass: 3 },
expand: { stiffness: 300, damping: 30 }, // snap point increases
collapse: { stiffness: 300, damping: 30 }, // snap point decreases
},
Use Transition.Specs.DefaultSpec for the library's default spring config.
Gestures
Configuration
options={{
gestureEnabled: true,
gestureDirection: "vertical",
...Transition.Presets.SlideFromBottom(),
}}
Gesture Options
| Option | Type | Description |
|---|---|---|
gestureEnabled |
boolean |
Enable swipe-to-dismiss |
gestureDirection |
string | string[] |
"horizontal", "horizontal-inverted", "vertical", "vertical-inverted", "bidirectional", or array |
gestureActivationArea |
"edge" | "screen" | object |
Where gesture starts. Object form: { left: "edge", right: "screen", top: "edge", bottom: "screen" } |
gestureResponseDistance |
number |
Pixel threshold for activation |
gestureVelocityImpact |
number |
How much velocity affects dismissal (default: 0.3) |
gestureDrivesProgress |
boolean |
Whether gesture controls animation progress (default: true) |
snapVelocityImpact |
number |
How much velocity affects snap targeting (default: 0.1) |
expandViaScrollView |
boolean |
Allow expansion from ScrollView boundary (default: true) |
gestureSnapLocked |
boolean |
Lock gesture snap movement to current snap point |
backdropBehavior |
"block" | "passthrough" | "dismiss" | "collapse" |
Backdrop touch handling |
backdropComponent |
React.ComponentType |
Custom backdrop component |
ScrollView Integration
Use Transition.ScrollView and Transition.FlatList so gestures coordinate with scroll position — the dismiss gesture only activates when scrolled to the boundary:
import Transition from "react-native-screen-transitions";
<Transition.ScrollView>{/* content */}</Transition.ScrollView>
<Transition.FlatList data={items} renderItem={renderItem} />
Custom Gesture Coordination
Use useScreenGesture to coordinate your own gestures with the navigation gesture:
import { useScreenGesture } from "react-native-screen-transitions";
const screenGesture = useScreenGesture();
const myPan = Gesture.Pan()
.simultaneousWithExternalGesture(screenGesture)
.onUpdate((e) => { /* your logic */ });
Snap Points (Bottom/Side/Top Sheets)
Snap points turn any screen into a multi-stop sheet. They work with any gesture direction.
Bottom Sheet
<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",
screenStyleInterpolator: ({
layouts: { screen: { height } },
progress,
}) => {
"worklet";
return {
contentStyle: {
transform: [{ translateY: interpolate(progress, [0, 1], [height, 0], "clamp") }],
},
};
},
}}
/>
Side Sheet
options={{
gestureEnabled: true,
gestureDirection: "horizontal",
snapPoints: [0.3, 0.7, 1],
initialSnapIndex: 1,
screenStyleInterpolator: ({
layouts: { screen: { width } },
progress,
}) => {
"worklet";
return {
contentStyle: {
transform: [{ translateX: interpolate(progress, [0, 1], [width, 0], "clamp") }],
},
};
},
}}
Programmatic Snap Control
import { snapTo, useScreenAnimation } from "react-native-screen-transitions";
const expand = () => snapTo(1);
const collapse = () => snapTo(0);
Animating Based on Snap Index
const animation = useScreenAnimation();
const style = useAnimatedStyle(() => {
const { snapIndex } = animation.value;
return {
opacity: interpolate(snapIndex, [0, 1], [0.5, 1]),
};
});
Backdrop Behavior
| Value | Description |
|---|---|
"block" |
Catches all touches (default) |
"passthrough" |
Touches pass through |
"dismiss" |
Tap dismisses the screen |
"collapse" |
Tap collapses to next lower snap point, then dismisses |
Custom Backdrop
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>
);
}
// In options:
backdropBehavior: "dismiss",
backdropComponent: SheetBackdrop,
Shared Elements (Bounds API)
Animate elements seamlessly between screens by tagging them with sharedBoundTag.
Steps
- Tag source with
Transition.Pressable:
<Transition.Pressable
sharedBoundTag="avatar"
onPress={() => navigation.navigate("Profile")}
>
<Image source={avatar} style={{ width: 50, height: 50 }} />
</Transition.Pressable>
- Tag destination with
Transition.View:
<Transition.View sharedBoundTag="avatar">
<Image source={avatar} style={{ width: 200, height: 200 }} />
</Transition.View>
- Use in interpolator via
bounds():
screenStyleInterpolator: ({ bounds }) => {
"worklet";
return {
avatar: bounds({ id: "avatar", method: "transform" }),
};
},
Bounds Options
| Option | Values | Description |
|---|---|---|
id |
string |
The sharedBoundTag to match |
method |
"transform" | "size" | "content" |
Animation method |
space |
"relative" | "absolute" |
Coordinate space |
scaleMode |
"match" | "none" | "uniform" |
Aspect ratio handling |
raw |
boolean |
Return raw values |
Complete Example (Instagram-Style Gallery)
// Source screen
function GalleryScreen() {
return (
<View style={styles.grid}>
{photos.map((photo) => (
<Transition.Pressable
key={photo.id}
sharedBoundTag={`photo-${photo.id}`}
onPress={() => router.push({
pathname: "/photo/[id]",
params: { id: photo.id, tag: `photo-${photo.id}` },
})}
>
<Image source={photo.thumb} style={styles.thumbnail} />
</Transition.Pressable>
))}
</View>
);
}
// Destination screen
function PhotoScreen() {
const { tag } = useLocalSearchParams();
return (
<Transition.MaskedView style={styles.container}>
<Transition.View sharedBoundTag={tag} style={styles.imageContainer}>
<Image source={photo.full} style={styles.fullImage} />
</Transition.View>
</Transition.MaskedView>
);
}
// Layout
<Stack.Screen
name="photo/[id]"
options={({ route }) => ({
...Transition.Presets.SharedIGImage({
sharedBoundTag: route.params?.tag ?? "",
}),
})}
/>
Overlays
Persistent UI elements (tab bars, mini players) that animate 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,
}}
/>
Overlay props: focusedRoute, focusedIndex, routes, progress, navigation, meta.
Hooks
| Hook | Purpose |
|---|---|
useScreenAnimation() |
Access progress, snapIndex, and animation values inside a screen |
useScreenState() |
Get index, focusedRoute, routes, navigation without animation overhead |
useHistory() |
Access navigation history — getRecent(n), getPath(from, to) |
useScreenGesture() |
Get the screen gesture ref for coordinating with custom gestures |
Transition Components
| Component | Purpose |
|---|---|
Transition.View |
View with sharedBoundTag support |
Transition.Pressable |
Pressable that measures bounds for shared elements |
Transition.ScrollView |
ScrollView with gesture coordination |
Transition.FlatList |
FlatList with gesture coordination |
Transition.MaskedView |
Reveal effects (requires @react-native-masked-view/masked-view) |
Do's and Don'ts
DO
| Practice | Why |
|---|---|
Add "worklet" directive in every interpolator |
Runs on UI thread — without it, animations drop frames |
| Use transform properties (translate, scale, rotate) | Transforms don't trigger layout recalculation |
| Extract complex interpolators to separate files | Keeps layout files readable; enables reuse |
Use Transition.ScrollView with gesture screens |
Coordinates scroll position with dismiss gestures |
Memoize screen components with React.memo |
Prevents unnecessary re-renders during transitions |
Use InteractionManager.runAfterInteractions for heavy work |
Defers expensive operations until animations complete |
Provide fallback values for sharedBoundTag (?? "") |
Prevents crashes when params are undefined |
| Test on real devices | Simulator animation performance can be misleading |
| Handle the exiting state (progress 1 → 2) | Screens need exit animations too, not just enter |
Type your navigation with RootStackParamList |
TypeScript catches param mismatches at compile time |
DON'T
| Anti-Pattern | Why |
|---|---|
| Use layout properties (width, height, top, left) in animations | Forces layout recalculation every frame — causes jank |
| Return different keys from interpolator on each call | Causes Reanimated to recreate animated nodes |
Use useState for animation values |
React state updates are async and trigger re-renders |
Use console.log inside worklets |
Can crash the app — worklets run on the UI thread |
| Use MaskedView in Expo Go | Requires native code — use a dev build |
| Ignore gesture conflicts with ScrollView | Use Transition.ScrollView / Transition.FlatList instead |
| Use this library for simple push/pop on low-end devices | @react-navigation/native-stack is faster for basic transitions |
| Block the JS thread during transitions | Heavy computations cause frame drops |
| Forget to install peer dependencies | Library depends on Reanimated, Gesture Handler, and React Navigation |
Performance Optimization
- Always use
"worklet"— ensures UI thread execution - Use transforms, not layout props —
translateXinstead ofleft,scaleinstead ofwidth - Defer heavy operations —
InteractionManager.runAfterInteractions(() => loadData()) - Memoize screens —
const Screen = memo(function Screen() { ... }) - Use
getItemLayoutwithTransition.FlatListfor known item sizes - Solid backgrounds — avoid transparency to reduce overdraw
detachInactiveScreens: true— limits screens kept in memoryexperimental_enableHighRefreshRate: true— for 90Hz/120Hz displays
Common Patterns
Music Player Bottom Sheet
<Stack.Screen
name="player"
options={{
gestureEnabled: true,
gestureDirection: "vertical",
snapPoints: [0.1, 0.3, 0.5, 0.75, 1.0],
initialSnapIndex: 0,
...Transition.Presets.SlideFromBottom(),
}}
/>
Modal with Custom Backdrop
options={{
gestureEnabled: true,
gestureDirection: "vertical",
backdropBehavior: "dismiss",
backdropComponent: ModalBackdrop,
screenStyleInterpolator: ({
layouts: { screen: { height } },
progress,
}) => {
"worklet";
return {
contentStyle: {
transform: [{ translateY: interpolate(progress, [0, 1], [height, 0], "clamp") }],
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
},
};
},
}}
Settings Drawer (Side Sheet)
options={{
gestureEnabled: true,
gestureDirection: "horizontal-inverted",
snapPoints: [0.8],
initialSnapIndex: 0,
backdropBehavior: "dismiss",
screenStyleInterpolator: ({
layouts: { screen: { width } },
progress,
}) => {
"worklet";
return {
contentStyle: {
transform: [{ translateX: interpolate(progress, [0, 1], [-width * 0.8, 0], "clamp") }],
width: width * 0.8,
},
};
},
}}
Onboarding Flow (Component Stack)
import { createComponentStackNavigator } from "react-native-screen-transitions/component-stack";
const Stack = createComponentStackNavigator();
function OnboardingFlow() {
return (
<Stack.Navigator initialRouteName="welcome">
<Stack.Screen
name="welcome"
options={{
screenStyleInterpolator: ({ progress }) => {
"worklet";
return {
contentStyle: {
opacity: interpolate(progress, [0, 1], [0, 1]),
transform: [{ translateX: interpolate(progress, [0, 1], [100, 0]) }],
},
};
},
}}
>
{() => <WelcomeScreen />}
</Stack.Screen>
</Stack.Navigator>
);
}
Reusable Preset Pattern
Extract your custom transitions into a presets file for consistency across your app:
// transitions/customPresets.ts
import Transition from "react-native-screen-transitions";
import { interpolate } from "react-native-reanimated";
import type { ScreenStyleInterpolator } from "react-native-screen-transitions";
export const CustomPresets = {
SlideFromRightWithScale: () => ({
screenStyleInterpolator: (({
progress,
layouts: { screen: { width } },
focused,
}) => {
"worklet";
if (focused) {
return {
contentStyle: {
transform: [{
translateX: interpolate(progress, [0, 1, 2], [width, 0, -width * 0.3]),
}],
},
};
}
return {
contentStyle: {
transform: [{ scale: interpolate(progress, [1, 2], [1, 0.95]) }],
},
};
}) as ScreenStyleInterpolator,
transitionSpec: {
open: Transition.Specs.DefaultSpec,
close: Transition.Specs.DefaultSpec,
},
}),
};
// Usage:
// options={{ ...CustomPresets.SlideFromRightWithScale() }}
Troubleshooting
| Issue | Solution |
|---|---|
| Jerky/laggy animations | Add "worklet" directive; use transforms not layout props; test on real device |
| Gestures not working with ScrollView | Use Transition.ScrollView / Transition.FlatList |
| Shared elements not animating | Verify sharedBoundTag matches on both screens; use Transition.Pressable (source) and Transition.View (destination) |
| Snap points not working | Verify snapPoints is number[] between 0-1; check initialSnapIndex is in bounds |
| Backdrop not responding | Check backdropBehavior; custom backdrops must handle press events |
| "Worklet" error | Add "worklet"; as first line in interpolator; verify Reanimated is configured |
| MaskedView crash in Expo Go | Use a development build — MaskedView needs native code |