The SharedXImage preset creates an X (Twitter)-style shared element transition where an image slides in from the top or bottom based on drag direction, with a morphing thumbnail-to-fullscreen effect.
This preset requires @react-native-masked-view/masked-view. See Masked View Setup for installation.
Function Signature
SharedXImage (
config : Partial < ScreenTransitionConfig > & {
sharedBoundTag: string ;
}
): ScreenTransitionConfig
Parameters
Configuration object with required sharedBoundTag The unique identifier that links the source and destination elements. Must match the sharedBoundTag on both Transition.Pressable (source) and Transition.View (destination).
...other
Partial<ScreenTransitionConfig>
Any other screen transition config properties to override
Returns
Configuration with vertical drag, dynamic entry direction, and bound morphing Enables custom transitions
Enables swipe-to-dismiss gesture
gestureDirection
array
default: "[\"vertical\", \"vertical-inverted\"]"
Allows swiping up or down to dismiss
Gesture affects dismissal but doesn’t directly drive progress
Interpolates bounds, overlay, and dynamic entry/exit direction
Standard spring configuration
Implementation Details
Transition Spec
transitionSpec : {
open : {
stiffness : 1000 ,
damping : 500 ,
mass : 3 ,
overshootClamping : true ,
restSpeedThreshold : 0.02 ,
},
close : {
stiffness : 1000 ,
damping : 500 ,
mass : 3 ,
overshootClamping : true ,
restSpeedThreshold : 0.02 ,
},
}
Screen Style Interpolator
The preset only animates the focused screen (unfocused screens remain static):
screenStyleInterpolator : ({
focused ,
bounds ,
current ,
layouts: { screen },
progress ,
}) => {
"worklet" ;
// Twitter doesn't animate the unfocused screen
if ( ! focused ) return {};
const boundValues = bounds ({
id: sharedBoundTag ,
method: "transform" ,
raw: true ,
});
// Content styles
const dragY = interpolate (
current . gesture . normalizedY ,
[ - 1 , 0 , 1 ],
[ - screen . height , 0 , screen . height ],
);
// Dynamically changes direction based on the drag direction
const contentY = interpolate (
progress ,
[ 0 , 1 ],
[ dragY >= 0 ? screen . height : - screen . height , 0 ],
);
const overlayClr = 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 ]);
// Bound styles - only enter animation
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 {
[sharedBoundTag]: {
transform: [
{ translateX: x },
{ translateY: y },
{ scaleX: scaleX },
{ scaleY: scaleY },
],
borderRadius ,
overflow: "hidden" ,
},
contentStyle: {
transform: [{ translateY: contentY }, { translateY: dragY }],
pointerEvents: current . animating ? "none" : "auto" ,
},
backdropStyle: {
backgroundColor: overlayClr ,
},
};
}
Key behaviors:
Dynamic Entry Direction
// If dragY >= 0 (swiping down), enter from bottom
// If dragY < 0 (swiping up), enter from top
const contentY = interpolate (
progress ,
[ 0 , 1 ],
[ dragY >= 0 ? screen . height : - screen . height , 0 ],
);
Bound Morphing Only on Open
// Bounds only animate during opening (not closing)
const x = ! current . closing ? boundValues . translateX : 0 ;
const y = ! current . closing ? boundValues . translateY : 0 ;
Drag-Responsive Overlay
// Overlay fades out as you drag
const overlayClr = interpolateColor (
current . progress - Math . abs ( current . gesture . normalizedY ),
[ 0 , 1 ],
[ "rgba(0,0,0,0)" , "rgba(0,0,0,1)" ],
);
Border Radius Animation
// Animates from 12px (thumbnail) to 0px (fullscreen)
const borderRadius = interpolate ( current . progress , [ 0 , 1 ], [ 12 , 0 ]);
Usage Example
Source Screen
Destination Screen
Layout Configuration
Custom Overlay Color
// app/timeline.tsx
import { router } from "expo-router" ;
import Transition from "react-native-screen-transitions" ;
export default function TimelineScreen () {
return (
< Transition.Pressable
sharedBoundTag = "tweet-image"
style = { {
width: 300 ,
height: 200 ,
borderRadius: 12 ,
overflow: "hidden" ,
} }
onPress = { () => {
router . push ({
pathname: "/image" ,
params: { sharedBoundTag: "tweet-image" },
});
} }
>
< Image
source = { { uri: "https://example.com/image.jpg" } }
style = { { width: "100%" , height: "100%" } }
/>
</ Transition.Pressable >
);
}
Key Features
Dynamic entry direction - Enters from top or bottom based on initial drag
Vertical-only gestures - Swipe up or down to dismiss
Asymmetric animation - Bounds morph on open, simple slide on close
Drag-responsive overlay - Backdrop fades as you drag
Border radius animation - Smooth corner transition
No masking required - Uses standard View, not MaskedView (simpler setup)
Comparison with Other Shared Presets
Feature SharedXImage SharedIGImage SharedAppleMusic Entry direction Dynamic (drag-based) Always from source Always from source Exit animation Simple slide Bounds morph Bounds morph Gestures Vertical only Multi-directional Multi-directional Shadows No No Yes (platform-specific) Masking No Yes Yes Background animation None None Scale down
Notes
Simpler than other shared presets : This preset doesn’t require Transition.MaskedView because it only animates bounds on open , not close. The close animation is a simple slide.
The entry direction adapts to your initial swipe. If you swipe down to dismiss, the next time you open it, it enters from the bottom. This creates a natural, predictable feel.
The overlay color calculation subtracts the drag distance from progress, so the backdrop fades out as you drag. This can create a transparent state during active dragging.