Zoom transition
Edit page
Learn how to use the zoom transition to create fluid animations between screens when using Expo Router for iOS.
Tip: To enable zoom transitions, runenableZoomTransition()in your app/_layout.tsx` file.
Zoom transitions provide a fluid animation effect when navigating between screens by zooming from a source element to the destination screen. This feature leverages iOS 18+ native zoom transition API to create shared, interactive transitions that produce a sense of spatial awareness between routes. For example, a card thumbnail may transition to become a full-width banner on the next route.
Get started
To implement zoom transitions, you need to use the Link.AppleZoom component to mark the source element and optionally Link.AppleZoom.Target to specify the target alignment on the destination screen.
Basic example
Here's a basic example showing how to create a zoom transition from an image to a preview screen. Start by enabling enableZoomTransition() in your root app layout file (app/_layout.tsx):
import { enableZoomTransition } from 'expo-router/internal/utils'; enableZoomTransition(); export default function Layout() { ... // your layout code }
Add Link.AppleZoom to wrap the source element (Image) in your screen component that you want to zoom from:
import { View, Text, StyleSheet, Pressable } from 'react-native'; import { Link } from 'expo-router'; import { Image } from 'expo-image'; export default function HomeScreen() { return ( <View style={styles.container}> <Link href="/image" asChild> <Link.AppleZoom> <Pressable> <Image source={{ uri: 'https://example.com/image-1.jpg' }} style={{ width: 100, height: 200 }} /> </Pressable> </Link.AppleZoom> </Link> </View> ); }
In the destination screen, define the Image component:
import { View, Text, StyleSheet } from 'react-native'; import { Image } from 'expo-image'; export default function DetailsScreen() { return <Image source={{ uri: 'https://example.com/image-1.jpg' }} style={{ flex: 1 }} />; }
Using Link.AppleZoom
The Link.AppleZoom component wraps the element you want to zoom from. It is useful for marking the source of the zoom transition, if you want to include additional elements alongside the zoomed content.
<Link href="/image" asChild> <Pressable> <Link.AppleZoom> <View>{/* Your content */}</View> </Link.AppleZoom> <Text>Subtitle</Text> </Pressable> </Link>
Link.AppleZoomonly accepts a single child component. If you need to wrap multiple children, use aViewor another container component.
Customizing alignment
You can specify the alignment of the zoomed element on the destination screen by using Link.AppleZoom.Target element.
export default function ImageScreen() { return ( <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}> <Link.AppleZoom.Target> <Image source={{ uri: 'https://example.com/image-1.jpg' }} style={{ width: '100%' }} /> </Link.AppleZoom.Target> </View> ); }
If you need more control over the alignment rectangle, you can pass an alignmentRect prop to Link.AppleZoom. However, this is normally not necessary if you use Link.AppleZoom.Target.
ThealignmentRectprop internally relies onalignmentRectProviderAPI.
<Link.AppleZoom alignmentRect={{ x: 0, y: 0, width: 200, height: 300 }}> <Image source={{ uri: 'https://example.com/image-1.jpg' }} style={{ width: 100, height: 150 }} /> </Link.AppleZoom>
Complete example
Here's a more complex example showing a gallery grid with zoom transitions to detail views. The source screen component (app/index.tsx) uses Link.AppleZoom to wrap an Image component:
import { Image } from 'expo-image'; import { Link } from 'expo-router'; import { useState } from 'react'; import { Text, Pressable, ScrollView, StyleSheet } from 'react-native'; const IMAGES = [ // Define your array of images here. ]; export default function Index() { return ( <ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollViewContent} contentInsetAdjustmentBehavior="automatic"> {IMAGES.map((_, index) => ( <Thumbnail key={index} index={index} /> ))} </ScrollView> ); } function Thumbnail({ index }: { index: number }) { const [size, setSize] = useState<{ width: number; height: number } | null>(null); return ( <Link href={{ pathname: `/image/[id]`, // You need to pass the image size to the detail page, so that the layout can be measured during the first render. params: { id: index, width: size?.width, height: size?.height }, }} asChild> <Pressable style={styles.thumbnail}> <Link.AppleZoom> <Image source={IMAGES[index % IMAGES.length]} style={styles.thumbnailImage} onLoad={e => setSize({ width: e.source.width, height: e.source.height })} /> </Link.AppleZoom> <Text style={{ textAlign: 'center' }}>Photo {index + 1}</Text> </Pressable> </Link> ); } const styles = StyleSheet.create({ scrollView: { flex: 1, backgroundColor: '#fff', padding: 16, }, scrollViewContent: { justifyContent: 'center', flexDirection: 'row', flexWrap: 'wrap', gap: 16, }, thumbnail: { width: 170, aspectRatio: 1, }, thumbnailImage: { width: '100%', height: '100%', borderRadius: 8, }, });
In the destination screen, the Link.AppleZoomTarget is used to specify the alignment of the zoomed element:
import { Image } from 'expo-image'; import { Link, useLocalSearchParams } from 'expo-router'; import { useMemo } from 'react'; import { StyleSheet, useWindowDimensions, View } from 'react-native'; export default function ImagePage() { const params = useLocalSearchParams(); const index = params.id ? parseInt(params.id as string, 10) : 0; const imageSource = IMAGES[index % IMAGES.length]; const imageSize = { width: parseInt(params.width as string, 10), height: parseInt(params.height as string, 10), }; const windowDimensions = useWindowDimensions(); // Compute the size to fit within the window while maintaining aspect ratio. const computedSize = useMemo(() => { if (!imageSize.width || !imageSize.height) { return { width: windowDimensions.width, height: windowDimensions.height }; } const widthRatio = windowDimensions.width / imageSize.width; const heightRatio = windowDimensions.height / imageSize.height; const minRatio = Math.min(widthRatio, heightRatio); return { width: imageSize.width * minRatio, height: imageSize.height * minRatio, }; }, [imageSize, windowDimensions]); return ( <View style={styles.container}> <Link.AppleZoomTarget> <View style={{ ...computedSize }}> <Image source={imageSource} style={styles.image} /> </View> </Link.AppleZoomTarget> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#fff', justifyContent: 'center', alignItems: 'center', }, image: { width: '100%', height: '100%', }, });
Platform support
Zoom transitions is only available on iOS 18 and later. On older iOS versions or other platforms, the component will render normally without the zoom animation effect.
The zoom transition components automatically detect platform support and gracefully degrade to standard navigation on unsupported platforms.
Known limitations
Using zoom transition with headers
We recommend avoiding the use of zoom transitions when navigating between screens that have a header (navigation bar). There are known issues with the native iOS zoom transition API that can lead to visual glitches or unexpected behavior when headers are involved.
Single child requirement
Both Link.AppleZoom and Link.AppleZoom.Target only accept a single child component. If you attempt to pass multiple children, a warning will be logged and the component will not render properly.
Incorrect:
<Link.AppleZoom> <View /> <Text /> </Link.AppleZoom>
Correct:
<Link.AppleZoom> <View> <Image /> <Text /> </View> </Link.AppleZoom>