iOS Photos Shared Element Transition with Expo and Reanimated

May 24, 2023

I really like the Shared Element Transition and overall animations of the Photos App on iOS. So I built a clone with the basic Shared Element Transition using Expo and React Native Reanimated. This was quite fun to build and can be used in various apps to display images in a nice animated fashion.
In the Video, you can see the Shared Element Transition on the iOS Photos app
And here is my implementation.
How can we do this?

Define Which Animations We Need

The animation is rather straight forward, but there are a couple of things we need to consider. Let’s first take a look at how the images are presented. Each image has a fixed width and height. But, they images do not fill the parent container, they are cropped if they do not fit the container while maintaining the original aspect ratio. This is important, as we need to consider this as well when animating.
Next is the start of the animation. The image seems to be zoomed in from the parent container in the list towards the center of the screen and takes full height and width while maintaining the aspect ratio.
The closing animation can be triggered with two occasions:
  • Either, the user pinches inwards and after a threshold is reached, the starting animation is reversed to get the image back to it’s original position
  • The user drags the image downwards, which will cause the same effect after another threshold is reached
Another thing is the background, as soon as the image is dragged down or pinched, the background get’s more transparent. Here we can also see that the image in the list has “vanished”, which is a nice little extra, as it gives the impression that the image was taken from the list to displayed in full screen.
I use a little trick here: The shared element transition is created by overlaying a fullscreen modal over the screen. When the modal is visible, the selected image get’s overlaywn by another image that is render within the modal, with the exact same coordinates. From there, we can start the animation and handle the gestures from the user. The original image in the list is “disabled” while the modal is open. We could also use a transparentModal and a own route to achieve the same thing. The needed information, like the coordinates, is shared via a context.

Implementing the Start Animation

To make the shared element transition, we need to know the coordinates of the image. I wrote a small component and helper function with which we can calculate the x and y coordinates of the image on the screen, as well as the visible part of the image, based on the contentFit. The wrapper is just a pressable around the image. We use the measure on a click and provided the original dimensions of the image to calculateVisibleImageDimensions. The function calculates the coordinates on the screen as well as the visible potion of the image, based on the parent, the image dimensions as well as the contentFit
export function SharableImage({
source,
placeholder,
contentFit = "contain",
asset,
}: SharableImageProps) {
const imageRef = useImage(source, {});
const prepareImageInformation = (measurement: MeasuredDimensions) => {
const imagePosition = calculateVisibleImageDimensions({
containerWidth: measurement.width,
containerHeight: measurement.height,
containerX: measurement.pageX,
containerY: measurement.pageY,
imageWidth: imageRef?.width ?? measurement.width,
imageHeight: imageRef?.height ?? measurement.height,
contentFit: contentFit,
});
};
const onPress = (event: GestureResponderEvent) => {
event.target.measure((_x, _y, width, height, pageX, pageY) => {
prepareImageInformation({
height,
width,
pageX,
pageY,
x: pageX,
y: pageY,
});
});
};
return (
<Pressable onPress={onPress} style={{ flex: 1 }}>
<Image
source={imageRef}
contentFit={contentFit}
placeholder={placeholder}
placeholderContentFit={contentFit}
allowDownscaling
style={{
flex: 1,
}}
/>
</Pressable>
);
}
Now, we can create a component that is render in the modal, containing our image. For the start animation, we want to have the following things:
  1. The image is resized to fit the whole screen while maintaining the aspect ratio
  2. Once the image is almost at full size, we want to animate the background to be not transparent anymore
Before starting the animation, we need to calculate the final width and height of the image, once the animation ends.
// Calculate dimensions that respect aspect ratio and screen constraints
const imageAspectRatio = image.width / image.height;
let finalWidth = windowWidth;
let finalHeight = windowWidth / imageAspectRatio;
// If height exceeds max height, scale down based on height
if (finalHeight > windowHeight) {
finalHeight = windowHeight;
finalWidth = windowHeight * imageAspectRatio;
}
const { width: visibleWidth, height: visibleHeight } =
calculateVisibleImageDimensions({
containerWidth: windowWidth,
containerHeight: windowHeight,
containerX: position.x,
containerY: position.y,
imageWidth: image.width,
imageHeight: image.height,
contentFit: contentFit,
});
// Take the minimum of calculated dimensions and visible dimensions
finalWidth = Math.min(finalWidth, visibleWidth);
finalHeight = Math.min(finalHeight, visibleHeight);
const { width, height } = {
width: useSharedValue(container.width),
height: useSharedValue(container.height),
};
The next thing we need to think about is the translation, we start with an initial translation, based on the provided coordinates (e.g. where the image’s original position on the screen is). From there, we want to center the image once the animation is finished. We can use the previous calculated dimension to determinate the target translate on both axis.
// Center the image in the screen
const TARGET_TRANSLATE_Y = (windowHeight - finalHeight) / 2;
const TARGET_TRANSLATE_X = (windowWidth - finalWidth) / 2;
const translate = {
x: useSharedValue(position.x),
y: useSharedValue(position.y),
};
Finally, we start the animation once the image is loaded. The animation changes the height and width as well as the translate on the X and Y axis.
useEffect(() => {
if (isLoaded) {
runOnUI(() => {
isAnimating.value = 1;
// Ensure we animate to the centered position
translate.x.value = withTiming(TARGET_TRANSLATE_X, { duration: 200 });
translate.y.value = withTiming(TARGET_TRANSLATE_Y, { duration: 200 });
width.value = withTiming(finalWidth, { duration: 200 });
height.value = withTiming(finalHeight, { duration: 200 });
})();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoaded]);

Implementing the Exit Animation

The exit animation is straight forward, we reset all the animation values to the initial values. I use `isAnimating` shared value to interpolate the source image’s opactiy.
const runExitAnimation = () => {
"worklet";
translate.x.value = withTiming(position.x, { duration: 200 });
translate.y.value = withTiming(position.y, { duration: 200 });
width.value = withTiming(container.width, { duration: 200 });
height.value = withTiming(container.height, { duration: 200 }, () => {
runOnJS(onPinchEndCallback)();
isAnimating.value = 0;
});
};

Implementing the Pan Animation

The pan Animation is rather simple. When the user starts dragging, the image's position and size are saved. As the user drags downward, the image moves and gradually scales down, by reducing the image dimensions - creating a natural zoom-out effect. The scaling is controlled by how far the image is pulled.
If the drag passes a certain threshold (just below the halfway point of the screen), we trigger the exit animation. If the drag isn’t far enough, the image snaps back to its original position and size.
const panGesture = Gesture.Pan()
.onStart(() => {
savedTranslate.x.value = translate.x.value;
savedTranslate.y.value = translate.y.value;
savedDimensions.width.value = width.value;
savedDimensions.height.value = height.value;
})
.onUpdate((event) => {
// Handle pan movement
const newTranslateY = savedTranslate.y.value + event.translationY;
// Scale down dimensions when dragging down
const scale = interpolate(
newTranslateY,
[
TARGET_TRANSLATE_Y,
TARGET_TRANSLATE_Y + 100,
TARGET_TRANSLATE_Y + 200,
],
[1, 0.8, 0.6],
Extrapolation.CLAMP
);
// Calculate new dimensions
const newWidth = savedDimensions.width.value * scale;
const newHeight = savedDimensions.height.value * scale;
// Calculate center point
const centerX = savedTranslate.x.value + savedDimensions.width.value / 2;
const centerY = savedTranslate.y.value + savedDimensions.height.value / 2;
// Update dimensions and adjust translation to maintain center
width.value = newWidth;
height.value = newHeight;
// Apply pan movement while maintaining center
translate.x.value = centerX - newWidth / 2 + event.translationX;
translate.y.value = centerY - newHeight / 2 + event.translationY;
})
.onEnd(() => {
// Check if the top border of the image has moved past half the screen height
const isExitTransition = translate.y.value > windowHeight / 2.2;
if (isExitTransition) {
runExitAnimation();
} else {
translate.x.value = withTiming(TARGET_TRANSLATE_X);
translate.y.value = withTiming(TARGET_TRANSLATE_Y);
width.value = withTiming(finalWidth);
height.value = withTiming(finalHeight);
}
});

Implementing the Pinch Animation

This pinch gesture enables a smooth zoom-out-to-dismiss interaction. When the user starts pinching, the current size and position of the image are saved.
As the user pinches inward (zooming out), the image’s actual dimensions shrink—giving a clear sense that the image is collapsing toward a focal point. The image stays anchored around the user's fingers by recalculating its position based on the pinch center.
Once the gesture ends, we check how much the image has shrunken. If it’s reduced below 90% of its original size, we invoke the exit animation. Otherwise, the image snaps back to its original size and position.
const pinchGesture = Gesture.Pinch()
.onStart(() => {
savedDimensions.width.value = width.value;
savedDimensions.height.value = height.value;
savedTranslate.x.value = translate.x.value;
savedTranslate.y.value = translate.y.value;
})
.onUpdate((event) => {
// Check if the user pinches out
if (event.scale < 1) {
const scaleFactor = event.scale;
const heightScale = interpolate(
event.scale,
[0, 0.2, 0.4, 0.6, 0.8, 1],
[0, 0.1, 0.2, 0.4, 0.7, 1],
Extrapolation.EXTEND
);
const newWidth =
savedDimensions.width.value *
(imageAspectRatio < 0.5 ? Math.max(scaleFactor, 0.3) : scaleFactor);
const newHeight =
savedDimensions.height.value *
(imageAspectRatio < 0.5 ? heightScale : scaleFactor);
width.value = clamp(newWidth, savedDimensions.width.value);
height.value = clamp(newHeight, savedDimensions.height.value);
// When zooming out, maintain the focal point between fingers on y-axis
const centerX = savedTranslate.x.value + event.focalX;
const centerY = event.focalY;
// Calculate focal ratio based on relative position within the image
const focalRatio = event.focalX / savedDimensions.width.value;
// Calculate new position to maintain focal point
// This ensures the image shrinks exactly at the point where user pinched
translate.x.value = centerX - newWidth * focalRatio;
translate.y.value = centerY - newHeight / 2;
}
})
.onEnd(() => {
// Calculate how much smaller the current dimensions are compared to final dimensions
const widthRatio = width.value / finalWidth;
const heightRatio = height.value / finalHeight;
const minRatio = Math.min(widthRatio, heightRatio);
// Exit if dimensions are significantly smaller than final dimensions
const isExitTransition = minRatio < 0.9;
if (isExitTransition) {
runExitAnimation();
return;
} else {
// Reset dimensions and translation to original when releasing
width.value = withTiming(finalWidth);
height.value = withTiming(finalHeight);
translate.x.value = withTiming(TARGET_TRANSLATE_X);
translate.y.value = withTiming(TARGET_TRANSLATE_Y);
}
});

Animation Styles

The background animation is the easiest part. We interpolate on the width shared value. As the image shrinks (usually from a pinch or drag gesture), its opacity fades out—starting from 0 when the width is 70% of the original, and reaching full visibility at 100% width.
const opacity = useAnimatedStyle(() => {
const oZoom = interpolate(
width.value,
[finalWidth * 0.6, finalWidth * 0.7, finalWidth * 0.8, finalWidth * 0.9, finalWidth],
[0, 0.5, 0.7, 0.9, 1],
Extrapolation.CLAMP
);
return {
opacity: oZoom,
};
});
Finally, we can apply the transforms to the Wrapper
const animationStyle = useAnimatedStyle(() => {
return {
transform: [
{ translateX: translate.x.value },
{ translateY: translate.y.value },
],
width: width.value,
height: height.value,
};
});
return (
<GestureDetector gesture={Gesture.Race(panGesture, pinchGesture)}>
<Animated.View
style={{
...StyleSheet.absoluteFillObject,
backgroundColor: "transparent",
}}
>
<Animated.View
pointerEvents="none"
style={[
{
backgroundColor: "white",
},
{
...StyleSheet.absoluteFillObject,
},
opacity,
]}
/>
<Animated.View
style={{
...StyleSheet.absoluteFillObject,
backgroundColor: "transparent",
}}
>
<Animated.View style={[animationStyle]}>
<Image
cachePolicy={"memory-disk"}
source={source}
contentFit={contentFit}
style={{ flex: 1 }}
allowDownscaling
onLoadEnd={() => {
setIsLoaded(true);
}}
/>
</Animated.View>
</Animated.View>
</Animated.View>
</GestureDetector>
);

Conclusion

Building a shared element transition inspired by the iOS Photos app using Expo and React Native Reanimated was a fun experience. It demonstrates how smooth, context-preserving animations can enhance the user experience for any different kind of apps - whether it’s a gallery, shopping platform, or news app. We can now take it from here and add other interactions once the initial animation is done, like zooming into the image or adding a carousel to swipe for the next image. By breaking down the animation into distinct phases—entry, exit, pan, pinch, and background fade—we’re able to deliver an interaction that feels both fluid and intuitive. With the composable API from Gesture Handler, it is super easy to write complex interactions.
If you're aiming to level up the UX of your React Native app with Shared Element Transitions, I hope this guide and the GitHub repo help you get started.

Loading repository information...

Feel free to fork the project, explore the code, and adapt it to your needs.
Happy animating - See you next time 🚀📸✨

Thanks for reading 👏

@codingfuchs
Freelance Fullstack Engineer | AWS Community Builder | Serverless & Frontend = ❤️
Follow on Twitter

Enjoyed this article?

Share it with your friends and colleagues.

Check out more articles on development, AWS, Expo, and technology.

View All Articles