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
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)
Return raw interpolated values instead of formatted styles. Useful for manual calculations.
Method Types
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) Maintain Aspect RatioScales both axes equally to maintain the original aspect ratio.bounds({ id: "image", scaleMode: "uniform" })
Best for: Images, avatars, icons where aspect ratio must be preserved No ScalingOnly translates position, no scaling applied.bounds({ id: "icon", scaleMode: "none" })
Best for: Elements that should maintain their size
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 }],
},
};
}
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>
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.