How to use shared element transitions in React Native

Published on Jan 19, 2021

15 min read

EXPO

cover_image

Originally Published at Logrocket

Transitions in mobile applications provide design continuity. This continuity is provided by connecting common elements from one view to the next while navigating in the app. This tutorial is going to provide a guide for you who is a React Native developer and is able to create such interfaces and make sure they are tangible.

Source code is available at this GitHub repo.

What are shared elements transition?

🔗

Transitions between different views or activities involve enter and exit transitions that animate the entire view hierarchies independent of each other. There are times when two different views in continuity have common elements. Providing a way to transit these common elements from one view to the second view and back, emphasizes the continuity between transitions. The nature of these transitions maintain focus for the end-users on the content and provides a seamless experience. A shared element transition determines how two different views share one or elements to maintain the focus and experience.

Pre-requisites

🔗

Before you begin, please make sure to have the following installed on a local environment:

  • Node.js version >= 12.x.x installed
  • Access to one package manager such as npm or yarn or npx
  • expo-cli installed, or use npx

Do note that to demonstrate I’ll be using an iOS simulator. If you prefer to use an Android device or an emulator, the code snippets shared in this post will run the same.

Install shared element transition libraries

🔗

To get started, let's create a new React Native project using expo-cli. From a terminal window, execute the command below and then navigate inside the newly created project directory. After navigating, install the libraries that are required in order to create shared element transitions. Let's use react-navigation from one screen to another using a stack navigation pattern.

To install the React Navigation library, please take a look at the following instructions from the official documentation. These dependencies change with time.

npx expo init shared-element-transitions
cd shared-element-transitions
yarn add @react-navigation/native react-native-animatable
expo install react-native-gesture-handler react-native-reanimated react-native-screens react-native-safe-area-context @react-native-community/masked-view
yarn add react-native-shared-element react-navigation-shared-element@next

After installing these libraries, let's checkout how to run the Expo app. From the terminal, run the yarn start command to trigger a build for the Expo app. Then depending on the simulator or the device, please select the correct option from the terminal prompt. For example, to run this app in its initial state on an iOS simulator, press i.

Here is how the output on an iOS simulator is shown:

lg1

This output verifies that the Expo app is up and running.

Create a home screen

🔗

The transition in this example app is going to be between a home screen and a details screen. The home screen is going to be a scrollable list of images and some data. I am going to use a set of the mock data array. You are free to use whatever data you might want to try out. Without bothering about the data set, you can use the mock data. Create a new directory called config/ and inside it create a new file called data.js with the following array and objects:

1export const data = [
2 {
3 id: '1',
4 title: 'Manarola, Italy',
5 description: 'The Cliffs of Cinque Terre',
6 image_url:
7 'https://images.unsplash.com/photo-1516483638261-f4dbaf036963?ixid=MXwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHw%3D&ixlib=rb-1.2.1&auto=format&fit=crop&w=633&q=80',
8 iconName: 'location-pin'
9 },
10
11 {
12 id: '2',
13 title: 'Venezia, Italy',
14 description: 'Rialto Bridge, Venezia, Italy',
15 image_url:
16 'https://images.unsplash.com/photo-1523906834658-6e24ef2386f9?ixlib=rb-1.2.1&ixid=MXwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHw%3D&auto=format&fit=crop&w=630&q=80',
17 iconName: 'location-pin'
18 },
19 {
20 id: '3',
21 title: 'Prague, Czechia',
22 description: 'Tram in Prague',
23 image_url:
24 'https://images.unsplash.com/photo-1513805959324-96eb66ca8713?ixid=MXwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHw%3D&ixlib=rb-1.2.1&auto=format&fit=crop&w=634&q=80',
25 iconName: 'location-pin'
26 }
27];

After that create a new directory called screens/ where the two app screens are going to live. Create a file inside it called HomeScreen.js and import the following statements.

1import React from 'react';
2import {
3 ScrollView,
4 Text,
5 View,
6 TouchableOpacity,
7 Image,
8 Dimensions
9} from 'react-native';
10import { StatusBar } from 'expo-status-bar';
11import { SimpleLineIcons } from '@expo/vector-icons';
12import { data } from '../config/data';

Using the Dimensions API from React Native, let's define the initial width and height of the image component. In the code snippet below, I am calculating both the width and the height using the width of the screen.

1const { width } = Dimensions.get('screen');
2
3const ITEM_WIDTH = width * 0.9;
4const ITEM_HEIGHT = ITEM_WIDTH * 0.9;

The HomeScreen component is going to be a functional React component that accepts one prop called navigation. It will allow the navigation from the Home screen to the DetailScreen. In any React Native app, the React Navigation library provides a context that further gives access to the navigation object as a prop automatically. The prop contains various functions that dispatch navigation actions.

1export default function HomeScreen({ navigation }) {
2 return (
3 <View style={{ flex: 1, backgroundColor: '#0f0f0f' }}>
4 <StatusBar hidden />
5 {/* Header */}
6 <View style={{ marginTop: 50, marginBottom: 20, paddingHorizontal: 20 }}>
7 <Text style={{ color: '#888', textTransform: 'uppercase' }}>
8 Saturday 9 January
9 </Text>
10 <Text style={{ color: '#fff', fontSize: 32, fontWeight: '600' }}>
11 Today
12 </Text>
13 </View>
14 )
15}

This functional component is going to render the header stating some dummy information to display and beneath it, a ScrollView to scroll through a list of images. Each image displays an icon and some information regarding what the image is about. This image and the text on it will play a huge role later when a transition is going to happen between the home and detail screen.

Inside the ScrollView component, let's render the mock data using JavaScript's map() method. If you are injecting data from a REST API that is hosted somewhere and you are not sure about the number of items in that particular data set, please use a FlatList component from React Native instead of ScrollView.

1return (
2 {/* Scrollable content */}
3<View style={{ flex: 1, paddingBottom: 20 }}>
4 <ScrollView
5 indicatorStyle='white'
6 contentContainerStyle={{ alignItems: 'center' }}
7 >
8 {data.map(item => (
9 <View key={item.id}>
10 <TouchableOpacity
11 activeOpacity={0.8}
12 style={{ marginBottom: 14 }}
13 onPress={() => navigation.navigate('DetailScreen', { item })}
14 >
15 <Image
16 style={{
17 borderRadius: 14,
18 width: ITEM_WIDTH,
19 height: ITEM_HEIGHT
20 }}
21 source={{ uri: item.image_url }}
22 resizeMode='cover'
23 />
24 <View
25 style={{
26 position: 'absolute',
27 bottom: 20,
28 left: 10
29 }}
30 >
31 <View style={{ flexDirection: 'row' }}>
32 <SimpleLineIcons size={40} color='white' name={item.iconName} />
33 <View style={{ flexDirection: 'column', paddingLeft: 6 }}>
34 <Text
35 style={{
36 color: 'white',
37 fontSize: 24,
38 fontWeight: 'bold',
39 lineHeight: 28
40 }}
41 >
42 {item.title}
43 </Text>
44 <Text
45 style={{
46 color: 'white',
47 fontSize: 16,
48 fontWeight: 'bold',
49 lineHeight: 18
50 }}
51 >
52 {item.description}
53 </Text>
54 </View>
55 </View>
56 </View>
57 </TouchableOpacity>
58 </View>
59 ))}
60 </ScrollView>
61</View>);

Create a detail screen

🔗

The DetailScreen component is going to render the details for each image that is part of the scroll list on the home screen. On this screen, an image is shown with a back navigation button that is positioned on the top of the screen. It receives the data in form of an item object that is destructured using route.params from React Navigation library. Beneath the image, it is going to show the title that will be shared with the home screen and some dummy text.

Create a new file called DetailScreen.js inside the screens/ directory and add the following code snippet:

1import React, { useRef } from 'react';
2import {
3 StyleSheet,
4 Text,
5 View,
6 ScrollView,
7 Image,
8 Dimensions
9} from 'react-native';
10
11import { SimpleLineIcons, MaterialCommunityIcons } from '@expo/vector-icons';
12
13const { height } = Dimensions.get('window');
14const ITEM_HEIGHT = height * 0.5;
15
16const DetailScreen = ({ navigation, route }) => {
17 const { item } = route.params;
18
19 return (
20 <View style={{ flex: 1, backgroundColor: '#0f0f0f' }}>
21 <Image
22 source={{ uri: item.image_url }}
23 style={{
24 width: '100%',
25 height: ITEM_HEIGHT,
26 borderBottomLeftRadius: 20,
27 borderBottomRightRadius: 20
28 }}
29 resizeMode="cover"
30 />
31 <MaterialCommunityIcons
32 name="close"
33 size={28}
34 color="#fff"
35 style={{
36 position: 'absolute',
37 top: 40,
38 right: 20,
39 zIndex: 2
40 }}
41 onPress={() => {
42 navigation.goBack();
43 }}
44 />
45 <View
46 style={{ flexDirection: 'row', marginTop: 10, paddingHorizontal: 20 }}
47 >
48 <SimpleLineIcons size={40} color="white" name={item.iconName} />
49 <View style={{ flexDirection: 'column', paddingLeft: 6 }}>
50 <Text
51 style={{
52 color: 'white',
53 fontSize: 24,
54 fontWeight: 'bold',
55 lineHeight: 28
56 }}
57 >
58 {item.title}
59 </Text>
60 <Text
61 style={{
62 color: 'white',
63 fontSize: 16,
64 fontWeight: 'bold',
65 lineHeight: 18
66 }}
67 >
68 {item.description}
69 </Text>
70 </View>
71 </View>
72 <ScrollView
73 indicatorStyle="white"
74 style={{
75 paddingHorizontal: 20,
76 backgroundColor: '#0f0f0f'
77 }}
78 contentContainerStyle={{ paddingVertical: 20 }}
79 >
80 <Text
81 style={{
82 fontSize: 18,
83 color: '#fff',
84 lineHeight: 24,
85 marginBottom: 4
86 }}
87 >
88 Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
89 eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad
90 minim veniam, quis nostrud exercitation ullamco laboris nisi ut
91 aliquip ex ea commodo consequat. Duis aute irure dolor in
92 reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
93 pariatur. Excepteur sint occaecat cupidatat non proident, sunt in
94 culpa qui officia deserunt mollit anim id est laborum.
95 </Text>
96 <Text
97 style={{
98 fontSize: 18,
99 color: '#fff',
100 lineHeight: 24,
101 marginBottom: 4
102 }}
103 >
104 Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
105 eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad
106 minim veniam, quis nostrud exercitation ullamco laboris nisi ut
107 aliquip ex ea commodo consequat. Duis aute irure dolor in
108 reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
109 pariatur. Excepteur sint occaecat cupidatat non proident, sunt in
110 culpa qui officia deserunt mollit anim id est laborum.
111 </Text>
112 </ScrollView>
113 </View>
114 );
115};
116
117export default DetailScreen;

Add navigation to the app

🔗

To navigate from the home screen to the detail screen and back, the app needs to have a navigation flow. This is going to be provided by createSharedElementStackNavigator method from react-navigation-shared-element module. It contains the React Navigation library for react-native-shared-element. This method allows us to create a stack-navigator which is the initial process of sharing elements between two separate screens. It wraps each route with the shared element and it detects route changes to trigger the transitions. The process of defining the navigation flow using this method is similar to React Navigation's stack-navigator module.

Create a new directory called navigation/ and inside it create a new file called RootNavigator.js. Import the following statements and create an instance called Stack of the createSharedElementStackNavigator method. Then define the Root Navigator.

1import * as React from 'react';
2import { NavigationContainer } from '@react-navigation/native';
3import { createSharedElementStackNavigator } from 'react-navigation-shared-element';
4
5import HomeScreen from '../screens/HomeScreen';
6import DetailScreen from '../screens/DetailScreen';
7
8const Stack = createSharedElementStackNavigator();
9
10export default function RootNavigator() {
11 return (
12 <NavigationContainer>
13 <Stack.Navigator headerMode="none" initialRouteName="HomeScreen">
14 <Stack.Screen name="HomeScreen" component={HomeScreen} />
15 <Stack.Screen name="DetailScreen" component={DetailScreen} />
16 </Stack.Navigator>
17 </NavigationContainer>
18 );
19}

To see it in action, modify the App.js file as shown below:

1import React from 'react';
2import RootNavigator from './navigation/RootNavigator';
3
4export default function App() {
5 return <RootNavigator />;
6}

Here is the result after this step in the iOS simulator:

lg2

Shared element mapping

🔗

The image component is going to be responsible to support a seamless back and forth transition between home and detail screen. This transition should happen from the scroll grid to the detail screen and back to the relevant image. To make this happen, wrap the Image component with <SharedElement> and provide a unique id to it in the HomeScreen.

Also, make sure to import the <SharedElement> component from the react-navigation-shared-element module.

1import { SharedElement } from 'react-navigation-shared-element';
2
3// Wrap the image component as
4return (
5 // ...
6
7 <SharedElement id={`item.${item.id}.image_url`}>
8 <Image
9 style={{
10 borderRadius: 14,
11 width: ITEM_WIDTH,
12 height: ITEM_HEIGHT
13 }}
14 source={{ uri: item.image_url }}
15 resizeMode="cover"
16 />
17 </SharedElement>
18);

The <SharedElement> component accepts a prop called id that is the shared id between the two screens. The child it is wrapped around is the actual component where the transition happens.

To enable the shared element transitions, the above process has to be followed in DetailScreen.

1import { SharedElement } from 'react-navigation-shared-element';
2
3// Wrap the image component as
4return (
5 // ...
6 <SharedElement id={`item.${item.id}.image_url`}>
7 <Image
8 source={{ uri: item.image_url }}
9 style={{
10 width: '100%',
11 height: ITEM_HEIGHT,
12 borderBottomLeftRadius: 20,
13 borderBottomRightRadius: 20
14 }}
15 resizeMode="cover"
16 />
17 </SharedElement>
18);

To animate the transition between the home and the detail screens, define a sharedElements configuration in the DetailScreen component. This will map the transition of the Image component between the two screens.

Before the export statement in DetailScreen.js add the code snippet:

1DetailScreen.sharedElements = route => {
2 const { item } = route.params;
3 return [
4 {
5 id: `item.${item.id}.image_url`,
6 animation: 'move',
7 resize: 'clip'
8 }
9 ];
10};

The config object above triggers the transition effects on shared elements between screens based on the unique ID shared between those two screens. This is done by defining a property called id.

The property animation determines how the animation is going to happen when navigating between two screens. For example, in the above code snippet, the animation has a value called move. It is also the default value of this property. There are other values available such as fade, fade-in, and fade-out. The property resize is the behavior that determines the shape and size of the element should be modified or not. For example, in the above snippet, the value clip adds a transition effect which is similar to a text reveal effect.

Here is the output after this step:

lg3

In the above example, please note that when the transition happens, the screen slides from left to right in between. To modify this behavior to apply transition effects of the shared elements, let's add an options configuration object to the DetailScreen. In Root Navigator file, add the following configuration:

1const options = {
2 headerBackTitleVisible: false,
3 cardStyleInterpolator: ({ current: { progress } }) => {
4 return {
5 cardStyle: {
6 opacity: progress
7 }
8 };
9 }
10};
11
12// Then add it to the DetailScreen
13
14return (
15 <Stack.Screen
16 name="DetailScreen"
17 component={DetailScreen}
18 options={() => options}
19 />
20);

The cardStyleInterpolator function specifies the interpolated styles for different parts of a card. It allows us to customize the transitions when navigating between two screens. It receives a property value called current.progress that represents the animated node progress value of the current screen. Applying this value to the property opacity changes the animated node to the value of animation defined in the shared element config object. Its cardStyle property applies the style on the view that is representing the card.

Update Shared elements mapping

🔗

In the previous demonstration, you can see that the transition on the image component is seamless but other components shared such as the location pin icon, the title and the description of the item between two screens is not.

To resolve this, let's map them using <SharedElement> component. First, in home screen, modify the following components:

1return (
2 // Icon
3 <SharedElement id={`item.${item.id}.iconName`}>
4 <SimpleLineIcons size={40} color='white' name={item.iconName} />
5 </SharedElement>
6
7 //Title
8 <SharedElement id={`item.${item.id}.title`}>
9 <Text
10 style={{
11 color: 'white',
12 fontSize: 24,
13 fontWeight: 'bold',
14 lineHeight: 28
15 }}
16 >
17 {item.title}
18 </Text>
19</SharedElement>
20
21 // Description
22 <SharedElement id={`item.${item.id}.description`}>
23 <Text
24 style={{
25 color: 'white',
26 fontSize: 16,
27 fontWeight: 'bold',
28 lineHeight: 18
29 }}
30 >
31 {item.description}
32 </Text>
33</SharedElement>
34);

Similarly, modify the following elements in DetailScreen.js file:

1// Icon
2<SharedElement id={`item.${item.id}.iconName`}>
3 <SimpleLineIcons size={40} color='white' name={item.iconName} />
4</SharedElement>
5
6// Title
7<SharedElement id={`item.${item.id}.title`}>
8 <Text
9 style={{
10 color: 'white',
11 fontSize: 24,
12 fontWeight: 'bold',
13 lineHeight: 28
14 }}
15 >
16 {item.title}
17 </Text>
18</SharedElement>
19
20// Description
21<SharedElement id={`item.${item.id}.description`}>
22 <Text
23 style={{
24 color: 'white',
25 fontSize: 16,
26 fontWeight: 'bold',
27 lineHeight: 18
28 }}
29 >
30 {item.description}
31 </Text>
32</SharedElement>

Then add the configuration:

1DetailScreen.sharedElements = route => {
2 const { item } = route.params;
3 return [
4 {
5 id: `item.${item.id}.image_url`,
6 animation: 'move',
7 resize: 'clip'
8 },
9 {
10 id: `item.${item.id}.title`,
11 animation: 'fade',
12 resize: 'clip'
13 },
14 {
15 id: `item.${item.id}.description`,
16 animation: 'fade',
17 resize: 'clip'
18 },
19 {
20 id: `item.${item.id}.iconName`,
21 animation: 'move',
22 resize: 'clip'
23 }
24 ];
25};

Here is the output after this step:

lg4

Delayed loading

🔗

Shared element transitions are a great way to support a smooth end-user experience but it can become tricky when dealing with elements that need to be loaded before or after the transition happens. For example, in the previous demonstration, the back button renders before the transition happens. To control its behavior, let's animate it using the React Native Animatable library.

Import it inside the DetailScreen.js file:

1import * as Animatable from 'react-native-animatable';

The close button icon is going to be wrapped inside the <Animatable.View>. This component has a prop called delay that delays the animation. Using a prop called duration you can control the amount of time the animation will run. Values to both of these props are provided in milliseconds. Using a ref value, the fadeOut animation is applied on the icon. This animation method is asynchronous and thus, you can use the promise to navigate back to the home screen after the animation has successfully run. The argument passed to this animation method is in milliseconds.

1const DetailScreen = ({ navigation, route }) => {
2 const buttonRef = React.useRef();
3
4 return (
5 <Animatable.View
6 ref={buttonRef}
7 animation="fadeIn"
8 duration={600}
9 delay={300}
10 style={[StyleSheet.absoluteFillObject]}
11 >
12 <MaterialCommunityIcons
13 name="close"
14 size={28}
15 color="#fff"
16 style={{
17 position: 'absolute',
18 top: 40,
19 right: 20,
20 zIndex: 2
21 }}
22 onPress={() => {
23 buttonRef.current.fadeOut(100).then(() => {
24 navigation.goBack();
25 });
26 }}
27 />
28 </Animatable.View>
29 );
30};

Here is the final output:

lg5

Conclusion

🔗

I hope you had fun reading this tutorial. Sharing elements in between screens in React Native using the React Navigation Shared Element module makes both the process of development and end-user experience smooth. I would recommend you to check out the official documentation here for more information.

Source code is available at this GitHub repo.


More Posts

Browse all posts

Aman Mittal author

I'm a software developer and a technical writer. On this blog, I write about my learnings in software development and technical writing.

Currently, working maintaining docs at 𝝠 Expo. Read more about me on the About page.


Copyright ©  2019-2024 Aman Mittal · All Rights Reserved.