react-native-screen-transitions
Version:
Easy screen transitions for React Native and Expo
486 lines (365 loc) β’ 15.5 kB
Markdown
# react-native-screen-transitions
| iOS | Android |
|---|---|
| <video src="https://github.com/user-attachments/assets/c0d17b8f-7268-421c-9051-e242f8ddca76" width="300" height="600" controls></video> | <video src="https://github.com/user-attachments/assets/3f8d5fb1-96d2-4fe3-860d-62f6fb5a687e" width="300" controls></video> |
## β¨ Features
- π― **Reanimated v3-4 Compatible** β Built for the latest React Native Reanimated
- π± **Cross-Platform** β Supports iOS and Android (web not supported)
- π· **TypeScript First** β Fully typed for better development experience
- π **Advanced Gestures** β Powered by react-native-gesture-handler with edge and screen activation areas
- π§ **Navigation Ready** β Works seamlessly with expo-router and react-navigation
- π **Shared Elements** β Bounds API for measure-driven transitions between screens
- π **Ready-Made Presets** β Instagram, Apple Music, X (Twitter) style transitions included
## Installation
```bash
npm install react-native-screen-transitions
# or
yarn add react-native-screen-transitions
# or
bun add react-native-screen-transitions
```
## Peer Dependencies
```bash
npm install react-native-reanimated react-native-gesture-handler \
-navigation/native @react-navigation/native-stack \
-navigation/elements react-native-screens \
react-native-safe-area-context
```
## Setup
### 1. Expo Router
```tsx
import 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.
### 2. React Navigation (bare)
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:
```tsx
import { createNativeStackNavigator } from 'react-native-screen-transitions';
const Stack = createNativeStackNavigator();
// Use Stack.Navigator and Stack.Screen as normal
```
### Extended native-stack options
This 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. |
| `gestureActivationArea` | `GestureActivationArea` | Where a gesture may start. `'edge' | 'screen'` or per-side `{ left|right|top|bottom: 'edge'|'screen' }`. |
### Renamed native options (extended stack)
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.
## Creating your screen animations
### Using presets
Pick a built-in preset and spread it into the screenβs options.
The incoming screen automatically controls the previous screen.
```tsx
<Stack>
<Stack.Screen
name="a"
/>
<Stack.Screen
name="b"
options={{
...Transition.presets.SlideFromTop(),
}}
/>
<Stack.Screen
name="c"
options={{
...Transition.presets.SlideFromBottom(),
}}
/>
</Stack>
```
#### Shared element presets (new)
Ready-made presets for common shared-element patterns. These leverage the bounds API under the hood. Tag your views with `sharedBoundTag` on both screens.
```tsx
<Stack.Screen name="feed" />
<Stack.Screen
name="post"
options={{
...Transition.presets.SharedIGImage(),
}}
/>
```
Other presets: `SharedAppleMusic()`, `SharedXImage()`.
#### π Masked View Setup (Required for SharedIGImage & SharedAppleMusic)
> **β οΈ Important**: These presets require native code and **will not work in Expo Go**. You must use a development build.
**1. Install the dependency**
```bash
# Expo projects
npx expo install -native-masked-view/masked-view
# Bare React Native
npm install -native-masked-view/masked-view
cd ios && pod install # iOS only
```
**2. Create a development build** (if using Expo)
```bash
npx expo run:ios
# or
npx expo run:android
```
**3. Wrap your destination screen**
```tsx
export default function PostScreen() {
return (
<Transition.MaskedView style={{ flex: 1, backgroundColor: 'white' }}>
{/* screen content, including the destination bound */}
</Transition.MaskedView>
);
}
```
> **π‘ Fallback behavior**: `Transition.MaskedView` will fall back to a plain `View` if the masked view library is missing, but this breaks the shared element effect and may cause errors like "bounds is not a function".
---
### Navigator-level custom animations
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` β combined progress of current and next screen transitions, ranging from 0-2.
- `current` β state for the current screen being interpolated (includes `progress`, `closing`, `gesture`, `route`, etc.).
- `previous` β state for the screen that came before the current one in the navigation stack (may be `undefined`).
- `next` β state for the screen that comes after the current one in the navigation stack (may be `undefined`).
- `layouts.screen` β `{ width, height }` of the container.
- `insets` β `{ top, right, bottom, left }` safe-area insets.
- `bounds(options)` β function that provides access to bounds builders for creating shared element transitions. See "Bounds" below.
- `activeBoundId` β ID of the currently active shared bound (e.g., 'a' when Transition.Pressable has sharedBoundTag='a').
- `focused` β whether the current screen is the focused (topmost) screen in the stack.
- `active` β the screen state that is currently driving the transition (either current or next, whichever is focused).
- `isActiveTransitioning` β whether the active screen is currently transitioning (either being dragged or animating).
- `isDismissing` β whether the active screen is in the process of being dismissed/closed.
```tsx
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.
### Screen-level custom animations with `useScreenAnimation`
For per-screen control, import the `useScreenAnimation` hook and compose your own animated styles.
```tsx
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>
);
}
```
## Swipe-to-dismiss with scrollables
You can drag a screen away even when it contains a scroll view.
Just swap the regular scrollable for a transition-aware one:
```tsx
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:
```tsx
<Stack.Screen
name="gallery"
options={{
enableTransitions: true,
gestureEnabled: true,
gestureDirection: 'vertical', // or 'horizontal', ['vertical', 'horizontal'], etc.
}}
/>
```
Use it in the screen:
```tsx
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.
### Gesture activation area
Control where gestures can start using `gestureActivationArea` on the screen options:
```tsx
// Gesture must start from any screen edge (all sides)
gestureActivationArea: 'edge'
// Allow vertical drags anywhere, horizontal drags only from the left edge
gestureDirection: ['vertical', 'horizontal']
gestureActivationArea: { top: 'screen', left: 'edge' }
```
## Bounds (measure-driven screen transitions)
Bounds let you animate any component between two screens by measuring its start and end positions. They are not shared elements β just measurements.
**Current Implementation:** For bounds to be measured, a `Transition.Pressable` must have both an `onPress` handler and a `sharedBoundTag`. When pressed, it triggers measurement. If the `Transition.Pressable` has children with `sharedBoundTag`s, those children are automatically measured and stored as well.
*Note: This measurement trigger mechanism may change in future versions.*
1) Tag source and destination with pressable triggers
```tsx
// Source screen
<Transition.Pressable
sharedBoundTag="hero"
onPress={() => router.push('/detail')}
style={{ width: 100, height: 100 }}
>
<Image source={...} />
</Transition.Pressable>
// Destination screen
<Transition.Pressable
sharedBoundTag="hero"
onPress={() => {/* handle press */}}
style={{ width: 200, height: 200 }}
>
<Image source={...} />
</Transition.Pressable>
```
2) Children are automatically measured
```tsx
<Transition.Pressable
sharedBoundTag="card"
onPress={() => router.push('/detail')}
>
{/* These children will be automatically measured when parent is pressed */}
<Transition.View sharedBoundTag="title">
<Text>Title</Text>
</Transition.View>
<Transition.View sharedBoundTag="subtitle">
<Text>Subtitle</Text>
</Transition.View>
</Transition.Pressable>
```
3) Drive the animation with the object API
```tsx
screenStyleInterpolator: ({ activeBoundId, bounds }) => {
"worklet";
const styles = bounds({
method: "transform", // "transform" | "size" | "content"
space: "relative", // "relative" | "absolute"
scaleMode: "match", // "match" | "none" | "uniform"
anchor: "center", // see anchors below
// target: "bound" | "fullscreen" | { x, y, width, height, pageX, pageY }
// gestures: { x?: number; y?: number }
});
return { [activeBoundId]: styles };
}
```
3) Raw values when you need them
```tsx
const raw = bounds({ method: "transform", raw: true });
// { translateX, translateY, scaleX, scaleY }
```
Or for size/content methods:
```tsx
const toSize = bounds({ method: "size", target: "fullscreen", space: "absolute", raw: true });
// { width, height, translateX, translateY }
const content = bounds({ method: "content", raw: true });
// { translateX, translateY, scale }
```
Anchors and scale
- `anchor`: "topLeading" | "top" | "topTrailing" | "leading" | "center" | "trailing" | "bottomLeading" | "bottom" | "bottomTrailing"
- `scaleMode`: "match" | "none" | "uniform"
Targets and space
- `target`: "bound" (default), "fullscreen", or explicit `{ x, y, width, height, pageX, pageY }`
- `space`: "relative" (within layout constraints) or "absolute" (window coordinates)
Gestures (sync focused screen deltas)
- `gestures`: `{ x?: number; y?: number }` adds live drag offsets to the computed transforms
Deprecated builder API
- The old chainable builder (`bounds().relative().transform().build()`) is deprecated. Migrate to the object form shown above. The builder remains temporarily for backward compatibility.
Quick access: `bounds.get()`
Use `bounds.get(id?, phase?)` to retrieve raw measurements and the resolved style for any bound in a given phase (`current`, `next`, `previous`).
```tsx
const { bounds: metrics, styles } = bounds.get('hero', 'current');
```
## Animating individual components with `styleId`
Use `styleId` to animate a single view inside a screen.
1. Tag the element:
```tsx
<Transition.View styleId="fade-box" style={{ width: 100, height: 100, backgroundColor: 'crimson' }} />
```
2. Drive it from the interpolator:
```tsx
screenStyleInterpolator: ({ progress }) => {
"worklet";
return {
'fade-box': {
opacity: interpolate(progress, [0, 1, 2],[0, 1, 0])
}
};
};
```
The red square fades in as the screen opens.
## Known Issues
- **Delayed Touch Events** β Thereβs a noticeable delay in touch events when the transition is finished. If this affects your app, please hold off on using this package until a fix is available.
## Support and Development
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.
### Support the project
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](https://buymeacoffee.com/trpfsu)
## License
MIT