rn-screen-transitions

Installation
SKILL.md

React Native Screen Transitions — Best Practices Guide

react-native-screen-transitions for 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

  1. Tag source with Transition.Pressable:
<Transition.Pressable
  sharedBoundTag="avatar"
  onPress={() => navigation.navigate("Profile")}
>
  <Image source={avatar} style={{ width: 50, height: 50 }} />
</Transition.Pressable>
  1. Tag destination with Transition.View:
<Transition.View sharedBoundTag="avatar">
  <Image source={avatar} style={{ width: 200, height: 200 }} />
</Transition.View>
  1. 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

  1. Always use "worklet" — ensures UI thread execution
  2. Use transforms, not layout propstranslateX instead of left, scale instead of width
  3. Defer heavy operationsInteractionManager.runAfterInteractions(() => loadData())
  4. Memoize screensconst Screen = memo(function Screen() { ... })
  5. Use getItemLayout with Transition.FlatList for known item sizes
  6. Solid backgrounds — avoid transparency to reduce overdraw
  7. detachInactiveScreens: true — limits screens kept in memory
  8. experimental_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
Related skills
Installs
4
GitHub Stars
1
First Seen
Mar 17, 2026