Advanced route transitions with React Transition Group and Popmotion [Part 1]
This tutorial will show how to replicate the App Store transition animation using React Transition Group and the Popmotion animation library. Part 1 focuses on bootstrapping the app.
Deprecation note
This tutorial was written more than a year ago. Since then, a lot has happened, Popmotion is now Framer Motion and offer more features. In the meantime, I decided to recreate the same prototype, using react-spring and react-use-gesture. The full commented code is available on codesandbox, and if you’re on a mobile you might want to try the full screen demo. The code follows some of the ideas explained here, but the setup has been simplified, and there’s now an iPad mode.
If you’re not interested in setting up routers and redux stores, skip over to Part 2.
Disclaimer
I’m no pro developer: although I work with developers, I’m no developer myself, and most of what I know is self-taught on my spare time. You won’t learn any code best practices (functional programming, HOC, performance tweaks), and if you look at the code you might find redundancies, errors or even worst practices. If so, please leave a comment.
I’m not a native English speaker: apologies in advance for all grammatical mistakes and clumsy syntax.
Code isn’t thoroughly tested: the code has been tested on an iPhone X running iOS 11.3 and on a Macbook Pro with Chrome 66. Unfortunately, some glitches remain on Safari desktop.
Copyright: for design purposes, I’ve been using illustrations I’ve shamelessly taken from Pinterest. If this causes any issues let me know and I’ll change them.
This series of posts highlight the basics for replicating – in a browser – the animation of the new App Store Today tab when moving from the article list to an article page. However, I won’t be focusing exclusively on how to mimic the animation itself but rather how to make this transition work in the context of a full packaged app with state management and routing, with the list and article pages having their own distinct URLs.
Also, I wanted the scroll on the iPhone to behave natively, with Safari address and tab bars retracting when scrolling down like they would on a normal web page. This may not look like a big deal, but I found this sole functionality to add major complexity as it requires clipping divs and manually resetting scroll positions. But we’ll come to this later.
All in all, our objective might not seem like an overly complex to achieve, but perfecting it was a long effort, looking at numerous StackOverflow and Github issues. So this is a comprehensive tutorial so that everything works great with no glitches. Hopefully someone will find this useful :)
If you don’t want to follow the tutorial and just want to see the prototype in action, click here. It’s going to be a long read, so if you want to make sure this is worth your time, I encourage you to have a look.
Main packages we will be using
- Yarn: a faster alternative to node packet manager
npm
. - The now famous create-react-app environment that helps setting up a feature-loaded React environment in no time.
- Sass for CSS styling (I suck at Sass so don’t expect to learn anything regarding Sass here).
- Router5: a universal and agnostic routing solution that I prefer over ReactRouter as it doesn’t mix with your Component tree.
- Redux for state management (but you won’t be learning much about Redux in this post).
- React Transition Group will let us animate components entering and leaving the DOM.
- Popmotion, a fast javascript DOM animation library.
- Pressure.js: very cool little library that handles Force Touch or 3D touch in the browser.
Note that Popmotion just released a React Pose library with a PoseGroup
component that could replace TransitionGroup
and possibly make the following code more declarative. I’ll have a look in the coming days and update this tutorial if necessary.
Animation analysis
Let’s start with our design goal. The App Store animation is — as usually with Apple, neatly crafted: easings and timings are perfect, everything reacts smoothly under the finger. We’re not going to reach the same level of perfection today, but we’ll try to get as close as we can.
Entering Transition
We’ll be calling entering transition the transition from the list page to the article page, and exiting transition the reverse transition from the article page to the list page.
The entering transition can be decomposed as below:
- the user taps the article preview in the list page.
- the article preview subtly bounces in…
- …before expanding full screen. Round borders become sharp and the content of the article seems to be overflowing from under the cover.
There are actually numerous more details in the App Store animation that we won’t be addressing in this article, such as the cover borders equally becoming sharp.
Exiting Transition
The exiting transition is slightly more complex to implement, as it is twofolds: tapping the close icon – or scrolling up to the point where the article page becomes draggable and can be released.
Tapping the close icon reverses the entering transition (with a spring easing) while at the same time scrolling the article up to the top. Dragging or actually scrolling up the article beyond its upper limit scales it down and reveals the list page blurred in the background. After a certain threshold, the article bounces back into the list. We’ll try to implement all of this in our app.
Agenda
This tutorial is going to be split in three parts:
- Part 1 (this part) focuses on setting up the app and its core routing mechanics and connecting the redux store. It also implements the basic components that we will use later on.
- Part 2 will get more interesting as it describes how to delegate the transition animation to the Popmotion library. We will cover the entering and exiting animations.
- Part 3 will show how to add the drag to close feature, and we’ll play with the Pressure.js library just to make things more complicated.
Getting started
It’s going to be a long run with a lots of files to create, so please be patient!
Setting up Create React App with Sass
I won’t get into much details about installing Create React App and creating an app. Just follow the instructions on the create-react-app project page and if you want to be running Sass, make sure you follow those steps.
Creating the Router and Store
As I’ve said before, the idea is to package the animation within a real-app environment, with routing and state management. There are numerous guides online that explain all this much better than I would do so I won’t spend much time on detailing the set up. But since Router5 is not as popular as ReactRouter yet, let me drive you through a few steps on how to make it work.
Let’s first add Redux and Router5 packages with their React / Redux relative dependencies:
yarn add redux react-redux redux-logger router5 react-router5 redux-router5 router5-transition-path
Then create two directories:
./src/_redux
that will contain the files needed to set up the redux store../src/_router
that will contain the files needed to set up the Router5 router and routes.
Let’s start with the router part.
Defining our routes
For the purpose of this prototype, we will be using local data. Create a file named postData.js
in ./src/_router
and paste this gist inside it.
We will only have two routes, one for the list page, and one for the article page. Create a routes.js
file inside the ./src/_router
directory, and paste the content below:
The home route will return all the posts (minus the content attribute that isn’t needed on the home page), while the post route will return only the data related to the post matching the id
passed as a parameter in the URL (i.e. http://localhost:3000/post/3
).
The onActivate
attribute is a function that we will call from a middleware — we will create it later as explained in the docs. onActivate
is responsible for retrieving the data needed to display each route. Even though the data is stored locally in ./src/_router/postData.js
, I’ve returned it as a Promise so that you can actually replace it with a fetch
call querying a distant API without changing much code.
The data fetching middleware
The data middleware will take care of triggering the route transition once the route data has been fully fetched.
The middleware is executed every time the router changes from one route to another. It returns an object that will be used as the state of the next route.
Create a new file ./src/_router/dataMiddleware.js
and paste the code below.
Essentially our middleware runs through all the routes that should be activated (in our case only one since we don’t have nested routes), and stores all onActivate
functions from our route into an array of Promises (remember we’ve written onActivate
to return a Promise).
Then the middleware waits for all these Promises to resolve (that’s the Promise.all
bit), reduces all the Promises results into one object, and adds this object as the data
attribute of toState
. Only when Promise.all
resolves and returns the new toState
object will the transition between the routes be finalized and dispatched to our redux store (that we haven’t created yet, this will come next). And only then will our transition animation start.
Note that the onActivate
function, the data
attribute of toState
can be labeled to whatever you want, they are not following any API. This is also why Router5 is so great.
I know this may look overly complicated considering that we’re fetching local data and that we only have one Promise to resolve, but I had a hard time myself figuring out how this worked the first time, so I though it might be worth including this part here.
Creating the router
Now let’s create the router in ./src/_router/router.js
with the code below.
We’re just copy pasting the boiler plate from Router5 documentation. Just one note though: scrollRestoration
is an attribute that was recently added to window.history
and that handles the scroll position of the page when you navigate from one URL to another (check the specifications for further information). We’re setting the scroll restoration to manual since we want to be in full control of how scroll is handled in our app.
Setting up Redux
I’ll be quick on this one. If you’re not familiar with redux, reducers or actions, maybe you should start with the awesome Redux documentation. If you just want to move on to the motion part, just copy paste the code in the right files and skip the explanations.
First, we’ll create a file in ./src/_redux/actions/index.js
that will combine all our reducers. We won’t be creating any custom reducers or actions for this prototype, so we only combine the router5 reducer that will add the router object to our app state:
Then, create a file in ./src/_redux/store.js
which exports a function that creates our store, adds the reducer we’ve just created and the router5 middleware. Again, I’m just copying elements from router5 official documentation.
Connecting the dots
Finally, we need to connect everything together. To do so, locate the index.js
file at the root of the ./src
directory and replace it with the content below:
There’s really nothing exotic there. We’re creating the router with everything we’ve seen before, wrapping our app inside Redux and Router5 providers and starting the router.
Let’s start the app!
Type yarn start
in the terminal and wait for create-react-app
to compile your code and start the web server. Your browser should open and, well… there’s nothing different from the default create-react-app
boilerplate.
However, if you open your browser console, you should see that everything is working as expected, and that our route of the app state has the data it needs for our page to display our list!
The front-end
Let’s start with the different components that make the front-end structure:
<App/>
main component of our React app.<List/>
is our home-route component, showing a list of<Preview/>
components.<Preview/>
shows the article title and thumbnail.<Post/>
is the article-route component, showing the article with its content. So our objective will be to transition fromList
toPost
.
We’re going to use the react-transition-group
package: add the dependency from your terminal:
yarn add react-transition-group
The <App/> component
Let’s start with the App
component. The file ./src/App.js
should be already created by create-react-app
, so just replace its content with the below:
If you look at the render
function, you’ll see that we have the List
component and then the TransitionGroup
component.
TransitionGroup
is part of the react-transition-group package and will be responsible for managing the entering and exiting animations of its children.
As you can see, Post
is a child of TransitionGroup
and should only show when the name of the route is equal to ‘post’
. Therefore, when we will browse from the home route to the post route, the TransitionGroup should trigger the entering transition of the Post
component.
navigateTo
is the action from the redux-router
package that allows to navigate from a route to another. We’re connecting it to the App
component and passing it as a prop to both List
and Post
since both these components will need to trigger route changes.
Finally, from redux-router5 documentation:
routeNodeSelector
is a selector designed to be used on a route node and works withconnect
higher-order component fromreact-redux
.
— Router5
In practice, routeNodeSelector
passes the current route object as a prop, and triggers rendering route nodes only when it needs to (but we’ll come to that later).
<List/> and <Preview/>
Create the file ./src/List.js
and paste the code:
In our case, List
is a simple component, displaying a list of post previews. As you can see, clicking on a Preview
instance should navigate to the post route, with id
passed as a parameter.
Again, we’re using routeNodeSelector
to pass the route object as a prop so that we can use the data fetched by our middleware in this.props.route.data
.
Now let’s move to the Preview
component inside ./src/Preview.js
:
The only thing worth noting is the data-id
html attribute that we’ll pass to the div
. We’ll need it later when executing the transition (yep, we’ll need to manipulate the DOM directly: as mentioned before, Popmotion released react-pose
which might change things).
The <Post/> component
Ok, so this is where everything happens, and we’ll take it slowly.
Create a ./src/Post.js
file and paste the code below:
Since Post
is going to be a children of a TransitionGroup
, we need to embed its content inside a CSSTransition
component. The reason why we’re using CSSTransition
instead of Transition
is because we’re going to use CSS classes to handle some parts of the transitions and because of this issue.
In this case, because we’ve set CSSTransition
prop classNames="post"
, the following classes are going to be added and removed to our component parent div
, and in this order: post-enter, post-enter-active, post-enter-done, post-exit, post-exit-active, post-exit-done.
Oh, and TransitionGroup
actually passes props to its children. In particular, the in
prop is a Boolean manages their in / out state, so we need to make sure we're passing all remaining props to the CSSTransition
component if we want it to work properly.
The routeNodeSelector('post')
makes sure the Post
component gets access to the route
object and also that only a post route or a post-nested route triggers a prop change.
Let me try to explain this better: here is what happens when triggering the exiting transition from the post route to the home route:
- [initial state] The route name is
'post'
and thePost
component usesthis.props.route.data
to display the post content. - [user action] User clicks on the close button, that redirects to the home route.
- [router transition] The router transitions to the home route.
- [animation starts] The current route name is now
'home'
. Sinceroute.name
is not equal to'post'
anymore, thePost
component should not be displayed any longer. Therefore,TransitionGroup
starts the exiting transition. However, if it wasn’t forrouteNodeSelector('post')
, thePost
component would receive the home route as a prop, and you would get an error when trying to accessthis.props.route.data.post
, because at this point, the route data actually contains a list of posts, and not an individual post. - [animation completes] Once the exiting transition is completed, the
Post
component is finally removed from the DOM.
Connecting routeNodeSelector('selector')
makes the connected component not care what happens to the router state as long as the route is not part of the 'selector'
node. So the Post
route prop won’t update when moving to the home route and the Post
component will keep its data intact. I know this sounds a bit fuzzy but just try changing routeNodeSelector('post')
to routeNodeSelector('')
(''
is the selector for all routes) and see what happens.
Starting the App
Ok, we should be good. We just need a bunch of CSS and images to make all this work, so create the following files: ./src/index.scss
, ./src/App.scss
, ./src/List.scss
and ./src/Post.scss
.
Now paste these CSS classes in their respective files, and place these images inside the ./public/img
directory.
Once you’ve saved the files, if you run yarn start
, then all scss
files should transpile into css
(and this is why we’re not importing scss
files in our JS in case you were wondering).
This is not a CSS tutorial (and again, I’m not good at Sass), but I just wanted to discuss the importance of the .page
class.
.page {
position: absolute;
top: 0;
display: flex;
justify-content: center;
min-height: 100vh;
}
As you can see, the outer div
s of both our List
and Post
components have the .page
class. The reason why .page
has an absolute
positioning is because when transitioning, Post
needs to be on top of List
. If we had a relative
positioning, Post
would naturally follow the DOM flow and be positioned under List
.
Testing the transition
You can now run the app with yarn start
and let’s see what happens when clicking on an item from the List
:
- The URL changes
- The
Post
appears right away
There’s one problem though. If you’ve scrolled through the List
, you’ll see that the Post
looks like it has been scrolled already. Also, if you scroll past the Post
content, you should see the rest of the List
overflowing. Don’t worry, we’ll fix this in Part 2.
This would actually be simple to fix if each page had height: 100vh
and overflow: scroll
CSS attributes, so the content would scroll inside the page. But remember, we want the native scroll so that the nav and tab bars of Safari mobile retract on scroll.
And now let’s click on the close icon of the Post
and go back to the List
.
- The URL changes
- The
Post
components disappears after about 1 second.
This is because of the CSSTransition
timeout prop, that we've set to 1000
ms: Post
is unmounted after 1 second. If you open the Inspector and select the .page-post
element once the post route is active, you should see the classes .post-exit
and .post-exit-active
being added until the element is finally removed from the DOM.
You might notice you will get an error if you load the site from the post route (so http://localhost:3000/post/2
for example). This happens because the List
component always shows no matter the route: when the initial route is the home route, then it gets the data it through the router middleware. However, when the initial route is the post route, then the component fails to render as no data is provided. Essentially, we want the List
page to render when the home route is active, or when a previous route exists (in a real life scenario we would probably be a little more strict in the way we handle our components, but this works in this case).
So in the App.js
file, replace the highlighted lines with the code below:
const { route, previousRoute, navigateTo } = this.props;/*...*/{(route.name === 'home' || previousRoute) && (
<List navigateTo={navigateTo} />
)}
That’s it for today. Here’s the link to the part-1
branch of the project should you need to take it from here.
Now let’s move on to the Part 2 of this tutorial to animate the transition.