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.
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 }}><Imagesource={imageRef}contentFit={contentFit}placeholder={placeholder}placeholderContentFit={contentFit}allowDownscalingstyle={{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:
- The image is resized to fit the whole screen while maintaining the aspect ratio
- 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 constraintsconst imageAspectRatio = image.width / image.height;let finalWidth = windowWidth;let finalHeight = windowWidth / imageAspectRatio;// If height exceeds max height, scale down based on heightif (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 dimensionsfinalWidth = 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 screenconst 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 positiontranslate.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 movementconst newTranslateY = savedTranslate.y.value + event.translationY;// Scale down dimensions when dragging downconst scale = interpolate(newTranslateY,[TARGET_TRANSLATE_Y,TARGET_TRANSLATE_Y + 100,TARGET_TRANSLATE_Y + 200,],[1, 0.8, 0.6],Extrapolation.CLAMP);// Calculate new dimensionsconst newWidth = savedDimensions.width.value * scale;const newHeight = savedDimensions.height.value * scale;// Calculate center pointconst centerX = savedTranslate.x.value + savedDimensions.width.value / 2;const centerY = savedTranslate.y.value + savedDimensions.height.value / 2;// Update dimensions and adjust translation to maintain centerwidth.value = newWidth;height.value = newHeight;// Apply pan movement while maintaining centertranslate.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 heightconst 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 outif (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-axisconst centerX = savedTranslate.x.value + event.focalX;const centerY = event.focalY;// Calculate focal ratio based on relative position within the imageconst focalRatio = event.focalX / savedDimensions.width.value;// Calculate new position to maintain focal point// This ensures the image shrinks exactly at the point where user pinchedtranslate.x.value = centerX - newWidth * focalRatio;translate.y.value = centerY - newHeight / 2;}}).onEnd(() => {// Calculate how much smaller the current dimensions are compared to final dimensionsconst widthRatio = width.value / finalWidth;const heightRatio = height.value / finalHeight;const minRatio = Math.min(widthRatio, heightRatio);// Exit if dimensions are significantly smaller than final dimensionsconst isExitTransition = minRatio < 0.9;if (isExitTransition) {runExitAnimation();return;} else {// Reset dimensions and translation to original when releasingwidth.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.Viewstyle={{...StyleSheet.absoluteFillObject,backgroundColor: "transparent",}}><Animated.ViewpointerEvents="none"style={[{backgroundColor: "white",},{...StyleSheet.absoluteFillObject,},opacity,]}/><Animated.Viewstyle={{...StyleSheet.absoluteFillObject,backgroundColor: "transparent",}}><Animated.View style={[animationStyle]}><ImagecachePolicy={"memory-disk"}source={source}contentFit={contentFit}style={{ flex: 1 }}allowDownscalingonLoadEnd={() => {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 👏
Enjoyed this article?
Share it with your friends and colleagues.
Check out more articles on development, AWS, Expo, and technology.
View All Articles