Skip to main content

Overview

Shared elements (also called hero transitions) animate UI elements smoothly between screens. The Bounds API automatically measures element positions and sizes, then interpolates between them during transitions.

Quick Start

1. Tag the Source

Mark the element on your first screen:
import Transition from "react-native-screen-transitions";

<Transition.Pressable
  sharedBoundTag="avatar"
  onPress={() => navigation.navigate("Profile")}
>
  <Image source={avatar} style={{ width: 50, height: 50 }} />
</Transition.Pressable>

2. Tag the Destination

Mark the corresponding element on your destination screen:
<Transition.View sharedBoundTag="avatar">
  <Image source={avatar} style={{ width: 200, height: 200 }} />
</Transition.View>

3. Use in Interpolator

Animate between the bounds:
options={{
  screenStyleInterpolator: ({ bounds }) => {
    "worklet";
    return {
      avatar: bounds({ id: "avatar", method: "transform" }),
    };
  },
}}

Bounds API

The bounds function returns animated styles that transition between source and destination measurements.

Basic Signature

bounds(options: BoundsOptions): StyleProps

Options

id
string
required
The sharedBoundTag to match between screens.
method
'transform' | 'size' | 'content'
default:"'transform'"
How to animate the element:
  • "transform": Translates and scales (uses scaleX/scaleY), no width/height
  • "size": Translates and sizes (uses width/height), no scale
  • "content": Screen-level transform that aligns destination screen so target bound matches source at progress start
space
'relative' | 'absolute'
default:"'relative'"
Coordinate space for calculations:
  • "relative": Relative to parent container
  • "absolute": Absolute screen coordinates
scaleMode
'match' | 'none' | 'uniform'
default:"'match'"
Aspect ratio handling:
  • "match": Scale X and Y independently to match target exactly
  • "none": No scaling applied
  • "uniform": Maintain aspect ratio (scale both axes equally)
raw
boolean
default:false
Return raw interpolated values instead of formatted styles. Useful for manual calculations.

Method Types

Transform Method

Use transforms for most shared element transitions:
screenStyleInterpolator: ({ bounds }) => {
  "worklet";
  return {
    "profile-image": bounds({
      id: "profile-image",
      method: "transform",
      scaleMode: "uniform",
    }),
  };
}
Returns:
{
  transform: [
    { translateX: number },
    { translateY: number },
    { scaleX: number },
    { scaleY: number },
  ]
}

Size Method

Animate width and height instead of scale:
screenStyleInterpolator: ({ bounds }) => {
  "worklet";
  return {
    "card-container": bounds({
      id: "card-container",
      method: "size",
    }),
  };
}
Returns:
{
  width: number,
  height: number,
  transform: [
    { translateX: number },
    { translateY: number },
  ]
}

Content Method

Align the entire destination screen:
screenStyleInterpolator: ({ bounds, focused }) => {
  "worklet";
  
  if (!focused) return {};
  
  return {
    contentStyle: bounds({
      id: "album-art",
      method: "content",
      scaleMode: "uniform",
    }),
  };
}
The content method is typically used with masked views for reveal effects like Instagram or Apple Music style transitions.

Scale Modes

Independent ScalingScales X and Y axes independently to match the target dimensions exactly.
bounds({ id: "card", scaleMode: "match" })
Best for: Elements where aspect ratio can change (cards, containers)

Coordinate Spaces

Relative Space

Default mode - coordinates relative to parent container:
bounds({ id: "avatar", space: "relative" })

Absolute Space

Absolute screen coordinates:
bounds({ id: "avatar", space: "absolute" })
Use space: "absolute" when elements are in different container hierarchies or when working with masked views.

Raw Values

Get raw interpolated values for custom calculations:
screenStyleInterpolator: ({ bounds }) => {
  "worklet";
  
  const rawValues = bounds({
    id: "card",
    method: "transform",
    raw: true,
  });

  // rawValues contains:
  // { translateX, translateY, scaleX, scaleY, width, height }

  const customScale = rawValues.scaleX * 1.2; // Apply custom factor

  return {
    card: {
      transform: [
        { translateX: rawValues.translateX },
        { translateY: rawValues.translateY },
        { scale: customScale },
      ],
    },
  };
}

Real-World Examples

Instagram-Style Image Expansion

From the SharedIGImage preset:
screenStyleInterpolator: ({
  bounds,
  focused,
  progress,
  layouts: { screen },
  active,
}) => {
  "worklet";

  const normX = active.gesture.normalizedX;
  const normY = active.gesture.normalizedY;

  const dragX = interpolate(
    normX,
    [-1, 0, 1],
    [-screen.width * 0.7, 0, screen.width * 0.7],
    "clamp",
  );
  
  const dragY = interpolate(
    normY,
    [-1, 0, 1],
    [-screen.height * 0.4, 0, screen.height * 0.4],
    "clamp",
  );

  const boundValues = bounds({
    id: "image",
    method: focused ? "content" : "transform",
    scaleMode: "uniform",
    raw: true,
  });

  if (focused) {
    const maskedValues = bounds({
      id: "image",
      space: "absolute",
      target: "fullscreen",
      method: "size",
      raw: true,
    });

    return {
      contentStyle: {
        transform: [
          { translateX: dragX },
          { translateY: dragY },
        ],
      },
      _ROOT_CONTAINER: {
        transform: [
          { translateX: boundValues.translateX || 0 },
          { translateY: boundValues.translateY || 0 },
          { scale: boundValues.scale || 1 },
        ],
      },
      _ROOT_MASKED: {
        width: maskedValues.width,
        height: maskedValues.height,
        borderRadius: interpolate(progress, [0, 1], [0, 24]),
      },
    };
  }

  return {
    image: {
      transform: [
        { translateX: dragX },
        { translateY: dragY },
        { translateX: boundValues.translateX || 0 },
        { translateY: boundValues.translateY || 0 },
        { scaleX: boundValues.scaleX || 1 },
        { scaleY: boundValues.scaleY || 1 },
      ],
    },
  };
}

Apple Music-Style Expansion

From the SharedAppleMusic preset:
screenStyleInterpolator: ({
  bounds,
  focused,
  progress,
  current,
}) => {
  "worklet";

  const boundValues = bounds({
    id: "album-art",
    method: focused ? "content" : "transform",
    anchor: "top",
    scaleMode: "uniform",
    raw: true,
  });

  const opacity = interpolate(
    progress,
    [0, 0.25, 1.25, 2],
    [0, 1, 1, 0],
    "clamp",
  );

  if (focused) {
    const maskedValues = bounds({
      id: "album-art",
      space: "absolute",
      method: "size",
      target: "fullscreen",
      raw: true,
    });

    return {
      contentStyle: {
        opacity,
        pointerEvents: current.animating ? "none" : "auto",
      },
      _ROOT_CONTAINER: {
        transform: [
          { translateX: boundValues.translateX || 0 },
          { translateY: boundValues.translateY || 0 },
          { scale: boundValues.scale || 1 },
        ],
      },
      _ROOT_MASKED: {
        width: maskedValues.width,
        height: maskedValues.height,
        borderRadius: interpolate(progress, [0, 1], [0, 24]),
      },
    };
  }

  const contentScale = interpolate(progress, [1, 2], [1, 0.9], "clamp");

  return {
    "album-art": {
      transform: [
        { translateX: boundValues.translateX || 0 },
        { translateY: boundValues.translateY || 0 },
        { scaleX: boundValues.scaleX || 1 },
        { scaleY: boundValues.scaleY || 1 },
      ],
      opacity,
    },
    contentStyle: {
      transform: [{ scale: contentScale }],
    },
  };
}

X (Twitter)-Style Image Viewer

From the SharedXImage preset:
import { interpolateColor } from "react-native-reanimated";

screenStyleInterpolator: ({
  focused,
  bounds,
  current,
  layouts: { screen },
  progress,
}) => {
  "worklet";

  if (!focused) return {};

  const boundValues = bounds({
    id: "tweet-image",
    method: "transform",
    raw: true,
  });

  const dragY = interpolate(
    current.gesture.normalizedY,
    [-1, 0, 1],
    [-screen.height, 0, screen.height],
  );

  const contentY = interpolate(
    progress,
    [0, 1],
    [dragY >= 0 ? screen.height : -screen.height, 0],
  );

  const overlayColor = interpolateColor(
    current.progress - Math.abs(current.gesture.normalizedY),
    [0, 1],
    ["rgba(0,0,0,0)", "rgba(0,0,0,1)"],
  );

  const borderRadius = interpolate(current.progress, [0, 1], [12, 0]);

  const x = !current.closing ? boundValues.translateX : 0;
  const y = !current.closing ? boundValues.translateY : 0;
  const scaleX = !current.closing ? boundValues.scaleX : 1;
  const scaleY = !current.closing ? boundValues.scaleY : 1;

  return {
    "tweet-image": {
      transform: [
        { translateX: x },
        { translateY: y },
        { scaleX },
        { scaleY },
      ],
      borderRadius,
      overflow: "hidden",
    },
    contentStyle: {
      transform: [{ translateY: contentY }, { translateY: dragY }],
      pointerEvents: current.animating ? "none" : "auto",
    },
    backdropStyle: {
      backgroundColor: overlayColor,
    },
  };
}

Masked Views

For Instagram and Apple Music style reveals, use Transition.MaskedView:
Requires @react-native-masked-view/masked-view. Will not work in Expo Go.

Installation

# Expo
npx expo install @react-native-masked-view/masked-view

# Bare React Native
npm install @react-native-masked-view/masked-view
cd ios && pod install

Usage

Wrap your destination screen content:
import Transition from "react-native-screen-transitions";

function DetailsScreen() {
  return (
    <Transition.MaskedView style={{ flex: 1, backgroundColor: "#121212" }}>
      <Transition.View
        sharedBoundTag="album-art"
        style={{
          backgroundColor: "#1DB954",
          width: 400,
          height: 400,
          alignSelf: "center",
          borderRadius: 12,
        }}
      />
      {/* Additional screen content */}
    </Transition.MaskedView>
  );
}

Remeasure on Focus

Re-measure elements when screens regain focus:
<Transition.View 
  sharedBoundTag="list-item"
  remeasureOnFocus={true}
>
  <Text>Dynamic Content</Text>
</Transition.View>
remeasureOnFocus
boolean
default:false
Re-measures this component when the screen regains focus and updates any matching shared-bound source link.Useful when layout can change while unfocused (e.g., programmatic ScrollView/FlatList scrolling triggered from another screen).

Available Components

All transition-aware components support sharedBoundTag:
<Transition.View sharedBoundTag="id" />
<Transition.Pressable sharedBoundTag="id" />
<Transition.ScrollView sharedBoundTag="id" />
<Transition.FlatList sharedBoundTag="id" />
<Transition.MaskedView sharedBoundTag="id" />

Tips

Use uniform scaling for images: Set scaleMode: "uniform" to maintain aspect ratio for images and avatars.
Source vs Destination: Transition.Pressable is ideal for source elements (automatically measures on press). Transition.View works for both source and destination.
Conditional bounds: Check focused to apply different bound methods for entering vs exiting screens.
Ensure both source and destination elements have the same sharedBoundTag value for the transition to work.