Advanced route transitions with React Transition Group and Popmotion [Part 2]

This is the second part of the tutorial. It will focus on the transition animation.

David Bismut
14 min readApr 9, 2018

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.

In Part 1 of this tutorial, we’ve set up the basics of the app, the redux store and the router are now functional. In the second part, we will actually animate the transition from our List page, to our Post page.

The entering animation

Now comes the fun part. The good news is that most of the work is going to be focused on the Post component, so we’re mostly going to touch Post.js and Post.scss files only.

First, let’s add the Popmotion dependency.

yarn add popmotion

From style and to style

I’ll be calling from style the position, size and style of our article when clicked in the List page, and to style its position, size and style when the animation is finished and the article is ready to be scrolled in the Post page. Our objective will be to animate from the from style to the to style.

The “from style” on the left, and the “to style” on the right

To make sure you can fully see what’s happening during the transition, add the following style attribute to the TransitionGroup component in App.js:

<TransitionGroup style={{opacity: 0.5}}>

Now open the Post.js file and update it accordingly:

Don’t forget to remove the timeout prop from CSSTransition!

So here is what the code does:

  • First, we add imports from popmotion and its related package stylefire. We’re not using them all yet, their time will come.
  • We’re adding from and to instance properties to the Post class. As you might guess, this.from and this.to will contain the from style and to style respectively.
  • In the render method, we’re now referencing the DOM element containing the post itself in this.post.
  • The componentDidMount method creates local properties this.postStyler and this.postScroll that are convenient setters and getters for style and scroll attributes of this.post.
  • onEnter prop has been added to CSSTransition and calls this.onEnter which is empty for now. onEnter is called right after the entering transition starts.
  • addEndListener prop has also been added to CSSTransition, and is calling this.onAddEndListener method. As per the documentation, addEndListener is called with the transitioning DOM node and a done callback. Allows for more fine grained transition end logic.
  • At the moment, onAddEndListener only calls the done function passed as a parameter to signal the end of the transition.
  • We’re adding instance method getPreviewStyleAndPosition that we will implement in a moment.
  • You might notice that we’ve also removed the timeout prop from CSSTransition. In fact, timeouts are no longer necessary when using addEventListener as a prop and the done callback.

If you run the app at this point, you should notice that the transition between the post route and the home route is executed instantly with no timeout. This is because CSSTransition relies on the done callback, which is called immediately.

The from style

The from style of our Post needs to perfectly match the Preview. Therefore, we need to access the DOM element of the Preview component to calculate its size, position and style. We can reference the preview node with the following code:

const { post } = this.props.route.data;this.preview = document.querySelector(`.preview[data-id="${post.id}"]`);

My natural instinct was to put this into componentDidMount, but we need to calculate the from style as soon as possible when the transition starts, in other words within the onEnter method. And onEnter is called even before componentDidMount. So we’ll reference the preview node in onEnter as below:

We’re also referencing the List page node (we will need this for handling scroll positions later on). And finally, we’re setting this.from to the result of getPreviewStyleAndPosition which we will deal with now.

Calculating the preview node position and style

We now need to fill in the getPreviewStyleAndPosition method so that it returns the preview node style and position.

As you might expect, we will be using getBoundingClientRect() to calculate the bounds of this.preview.

In Post.js, fill the getTransition method with the following code:

In the onAddEndListener method, add the following line and comment out the done statement (this will prevent the transition from completing and help us work on the from style as the post page will remain mounted undefinitely):

this.postStyler.set(this.from);
// done();

The line above sets the post node style to the style of the preview node returned by this.from.

If you run the app now, scroll the List page and transition from to the Post page, then you’ll immediately notice that our post node and the preview node don’t match.

Post from state don’t match with the preview node. I’ve added a red border on the post node for clarity.
From the inspector, you should see that the post node does get passed styling attributes.

The explanation is simple: from our CSS class .post, the post node gets an absolute positioning.

However, getBoundingClientRect() returns coordinates relative to the viewport while a DOM element with an absolute positioning is relative to the parent element, which might have been scrolled. Good news, a fixed positioning is relative to the viewport and independent from its parent.

Try changing the post node position to fixed in the inspector and you should see that the post node matches the position of the preview node.

However, the content is still overflowing from the post node. Again, easy fix, add overflow: hidden to the style of the post node, and everything should now be aligned perfectly.

Add position:fixed and overflow: hidden to the post node. It should align perfectly with the preview node.

Great, we’re getting somewhere: what we want to do is fixing the position and hiding the overflow to the post node every time a transition is active (we’re dealing with the entering transition right now, but the reasoning stands for the exiting transition). Fortunately for us, we can do this in CSS directly, since the CSSTransition group automatically adds .post-enter classes for us to the Post page. So just add the following rules to the Post.scss file:

pointer-events: none also makes sure our post node doesn’t capture any events while transitioning.

Run the app again, and you can see that our post node is now perfectly aligned with the preview node.

Ok, we now need to set the to style that matches our destination position and run the animation.

Calculating the destination style and position

Our destination is the expanded post page. Calculating its position is rather straightforward: it’s aligned to the top of the window, should have the same height as the viewport, the same width as the body and sharp corners.

In onEnter, set this.to as below:

In onAddEndListener, add the following lines:

The interesting part happens with the tween statement (read the Popmotion documentation for a more thorough explanation). In a few words, tween takes two main parameters (from and to) and returns an action you can start. This action runs an update function that is called every time the tween updates, and a complete callback that is called once the tween reaches the to value.

What we’re doing here is updating the style of the post node in the update method, and once it completes, done is called to let TransitionGroup know that the transition is completed.

If you run the code at this point, you should see the post node animate smoothly until it reaches its final state… but after the done callback is triggered, the scroll position messes with us again.

After the transition is finished, the post node jumps back up :(

Fixing the scroll

What happens here is pretty simple: once the .post-enter class is removed from the Post page, the post node retrieves its absolute positioning and follows the scroll position of the viewport (which was set when scrolling the List page).

Therefore, once the transition is completed, we just need to set the scroll position of the viewport to the top (i.e. 0) and our post node should stay to the top!

Popmotion stylefire package provides a scroll renderer that comes handy in this situation. In Post.js, even before the Post class is declared, add the line below:

const windowScroll = scroll();

As you can see from the docs, when initiating scroll with no arguments, you get a viewport position renderer, which means you can get and set the window scroll position easily.

In the complete function from our tween, just before the done() statement, add the following line:

windowScroll.set('top', 0);

Now run the app again.

Better. But as you can see, there is a problem with the List page below.

You should get something similar to the capture above. This time, the post element is positioned correctly and everything would look fine if we hadn’t set the opacity of the TransitionGroup to 0.5. But we can observe that the List page underneath jumps back to the top.

You might think no big deal since we can’t see it underneath. But actually when we will close the post page and trigger the exiting transition, we want the List page to be positioned just like it was before the entering transition.

We will use the same trick of the fixed position that we’ve seen before. On the List page, try scrolling the viewport and then styling the .page-list node with a fixed position from the Inspector. You should see the List page jumping back to the top, and that’s expected because fixed elements ignore the viewport scrolling position.

So position: fixed is not enough, we also need to set the top attribute of the List page style to the scroll position of the viewport. In other words:

const scrollTop = windowScroll.get('top');
document.querySelector('.page-list').style.position = 'fixed';
document.querySelector('.page-list').style.top = -scrollTop;

As you might recall, we’ve set up a style helper for the page list node. So the block above translates into:

const scrollTop = windowScroll.get('top');
this.pageListStyler.set({ position: 'fixed', top: -scrollTop });

Add the two lines above right at the end of onEnter, and while we’re at it, you can even move the windowScroll.set('top', 0) out of the complete block in onAddEndListener into onEnter.

Remove the style on the TransitionGroup component in App.js, and back to the browser again, and… yes! The entering transition works as expected.

The entering transition seems to work ok!

Testing on an iPhone

You thought this was over… Well unfortunately, things don’t work as smoothly on mobile Safari (at least on my iPhone X):

Glitches on my iPhone X and iOS Safari :(

First, there’s a visible flash before the transition starts. Then, once the transition completes, there’s a weird scroll glitch, and the top of the Post page seems to be under the navigation bar.

Fixing the flash

The flash happens because the image of the Post hasn’t loaded and isn’t rendered when the Post mounts, although in our situation the image the same as the one in the List preview.

That might be a problem with the way Safari caches images. Anyway, no big deal since in a real-world situation, the Post might use a larger version of the List thumbnail image so we would have to wait for the image to load.

So let’s wait for the image to load before triggering the animation.

You will need to move some of the current code of onAddEndListener into to proxy method, let’s call it executeEnteringTransition. In onAddEndListener, we will add the image onload listener as so:

Right before we execute the transition, we’re creating an Image object that shares the same source as the Post thumbnail.

If the image is not loaded (i.e. img.complete === false), we wait for it to load and then we execute the transition. If the image is completed already, then we execute the transition immediately.

Let’s run the app again from an iPhone.

Well again, we have a flash. An even bigger one (I won’t make the capture this time). The reason why we see that flash is because while the image loads, the post component still mounts and shows before we’ve set the post node style to the from style.

The fix is simple. We need the post node visibility to be hidden when it mounts, and only once the image has loaded, set its visibility to visible.

So, in Post.scss, add the following class:

.page-post.post-enter .post {
visibility: hidden;
}

In Post.js, we’re going to have to set the post node visibility to visible manually as so in executeEnteringTransition:

this.postStyler.set({ ...this.from, visibility: 'visible' });

And this time, the appearing glitch has gone away!

Fixing the scroll issue

Unfortunately, I’m not exactly sure what happens with the scroll glitch. I guess that preventing elements from scrolling makes the nav and tab bars come into the viewport, which messes with the top style attribute when the animation runs.

I’m not super proud of the fix, but the idea is that once our tween is completed, we animate the scroll of the viewport to 0. So, replace the complete statement of our tween with the following:

Again, we tween the value of the viewport scroll to zero, and once that tween is completed, we can call the done callback.

This time everything works! Easing is not perfect, but we’ll work on it at the end of this part.

Ok now we can safely move on to the exiting transition.

Here is the code up to this point in the part-2-entering-transition branch, and let’s take a break before moving onto the exiting animation.

The exiting animation

First, we need to detect if we’re entering or exiting within the onAddEndListener method. Fortunately, TransitionGroup passes a in prop to its children that defines whether they should be displayed or not.

When the <Post/> component is entering, its in prop is true, false when it is exiting. So our logic in onAddEndListener will be different depending on this prop.

Create a new executeExitingTransition with the same arguments as executeEnteringTransition and with done() as the only statement.

Following the same logic as onEnter, add the onExit prop to CSSTransition and make it call an empty onExit method that you should create.

Change the onAddEndListener method so that it calls executeEnteringTransition or executeExitingTransition depending on the in prop:

Our work at this point will be to implement onExit and executeExitingTransition methods.

onEnter sets this.from to the preview node style and this.to to the post node style. For the exiting transition, onExit should do the exact opposite.

You might wonder why we don’t set borderRadius to 0 right away, well, you’ll see this in Part 3.

Now, let’s get to the executeExitingTransition and add the following code:

This time, instead of a tween, we will be using a spring. You can check the Popmotion documentation to explore the different parameters, but it works more or less the same as a tween. In the complete method, we free the List page and set the viewport scroll to what it was before the entering transition.

Before moving on, modify your Post.scss file to also set the .post position and overflow to fixed and hidden when the exiting transition is running, which happens when the .post-exit class is added to our post page node.

.page-post {
&.post-enter,
&.post-exit { // add just this line here
.post {
position: fixed;
overflow: hidden;
pointer-events: none;
}
}
}

Hiding the preview node

If you test the app now, you’ll see that it works pretty well for a first try. However, if you remove style={{ opacity: 0.5 }} prop from TransitionGroup in App.js, you will see that when bouncing back in, the preview node can clearly be seen underneath.

Red borders added to the post node for clarity.

Well this is pretty easy to fix: we need to hide the preview node when starting the entering transition, and show it once the exiting transition is completed.

In executeEnteringTransition, as the second statement, type:

this.preview.style.visibility = 'hidden';

And then, at the start of the complete block of executeExitingTransition:

this.preview.style.visibility = 'visible';

This should be better now.

Handling the Post scroll

We need to check what happens when scrolling the Post content and then closing it.

Click on the second preview post — it has the longest content, scroll through it, and click the close button. This shouldn’t look too bad, and that might be sufficient for some. But let me use a slower animation and let’s see what’s really happening here (replace the spring with a tween and a 10000 duration).

You can see the Post preview image jumping in.

As you can see, as soon as we clicked the close button, the Post looks like it’s scrolling to top instantly. This is again because of the fixed position we’re applying to the post node when transitioning. So before the transition even starts, we need to make sure we’ve set the post node to the correct scroll position.

We’ll be using a different technique than for the List page and use a helper class called .scroll-block:

Add the class wherever you want, I’ve add it in App.scss since you might want to use in other components.

Here is what we will be doing to clip the post node to the correct scroll position:

const scrollTop = windowScroll.get('top');
this.post.classList.add('scroll-block');
this.postScroll.set('top', scrollTop);

First line get’s the viewport scroll position. Then we add the .scroll-block class to the post node. And finally, we set the post scroll position to the previous viewport scroll position.

Remember, we want to add this bit of code, as soon as the exiting transition starts. onExit seems like the perfect place for this.

Add the block of code above at the end of the onExit method:

Run the app, and you’ll see that nothing has changed.

Here’s what’s happening:

  1. The transition starts.
  2. .post-exit class is added to the post page, and our post node gets fixed position and hidden overflow.
  3. onEnter is fired and we calculate the viewport scroll and set the .scroll-block class to the post node.

The problem is that on step 3, the viewport scroll is equal to zero. In fact, as soon as the our post node gets a fixed position, the viewport doesn’t have any content to scroll, and therefore its position is reset to 0. The fix is simple. We don’t want the post node to get a fixed position when the .post-exit class is added by CSSTransition.

Remove the &.post-exit rule in Post.scss, run the app, and do the same test as before: you should notice that the Post starts from the right position, but it doesn’t scroll up.

Clipping works, but the preview image doesn’t show and the transition is not seamless.

Again, let’s fix this. What we want to do is also animate this.postScroll during the exiting transition so that it transitions back to 0.

Change onExit so that this.from and this.to also include scrollTop values to interpolate from.

Then, modify executeExitingTransition with the below:

As you can see, we’re decomposing the update function to animate this.postStyler and this.postScroll separately.

Run the app again and…

It works!

Change the tween back to a spring, and you should have a nice and cool bouncing back animation :)

One last thing: easing on entering and fixing one last flash

Before moving to Part 3, let’s make the entering animation pop a bit more. Add the following line to the top of the Post.js file, right after the import statements.

const myEasing = cubicBezier(0.8, -0.25, 0.33, 1.52);

Then in executeEnteringAnimation, change the duration of the tween to 800, and add ease: myEasing as an additional parameter (just under duration). Run the entering transition and you should have a subtle back in back out effect.

You might also observe a very quick white flash on your iPhone, during the entering transition. After quite a lot of digging, I’ve found out that this happens right at the beginning of executingEnteringTransition, when setting the post node visibility to visible, and the preview node visibility to hidden. Since Chrome handles it well, I’m going to do something I hate, a conditional block for Safari.

Add the bowser dependency:

yarn add bowser

Then tweak Post.js with the following code:

Code at this point is available on the part-2-complete branch.

What’s next?

We’ve done the basics, but we still have job to do. Part 3 will cover:

  • Implementing the drag to close functionality on iOS
  • Playing with Pressure.js
  • A few more little details

--

--