Shared element transitions or how to fight Cumulative Layout Shift

Pavlik Kiselev
ING Blog
Published in
5 min readNov 3, 2022

A concise story about how to make a smooth transition between pages if you don’t know the exact size of the coming elements with a new browser API proposal. If you build interfaces, this story is for you.

At our Fraud Prevention and Investigation department, we have a sort of hackathons now and then. They are called Do Your Thing days. On one of these days, I had a chance to try out the new Shared Element Transitions API for our login page.

mijn.ing.nl/login

The page does not have a lot. The topbar with a logo and language switch, a card with a notification and a login form, and some links after the card. Even with few elements and relatively fast loading time, we are trying our best to improve UX. Therefore, we first load a skeleton screen to improve the perceived performance.

mijn.ing.nl/login skeleton

This screen has only a topbar with a logo and a card with a loading throbber. And now comes the most challenging part. The notification from the login page is loaded from the backend and has a dynamic content, and, therefore, height. So, the wrapping card also has a dynamic height. And this is where our login page has the biggest (and the only) Layout Shift.

Card jumps for 100px. Because it’s centered vertically, it jumps top/bottom by 50px in this example.

One of the solutions consists of these four steps:

  1. Show the skeleton screen with the minimum height 428px. This height corresponds with the minimum possible height of the coming login card when there is no notification. Let’s call it outgoing height.
  2. Load the coming login card in the background.
  3. Render notification somewhere off-screen and measure the height of the card with it. Let’s call it incoming height.
  4. Animate the card from outgoing height to incoming height. In our example, the incoming height is 428 + 100 = 528px.

With shared element transition, the solution can be done with twenty-two lines of code, including JS, HTML, and CSS!

But first, what is the Shared Element Transition API?

Shared Element Transition API

Simply put, this API allows you to animate a transition from one element to another. It’s not necessarily the same selector on a different screen, it’s more like “logically” the same element. For example, if you have a list and click on an item in this list. This API allows a smooth transition between this list and a details view.

The excellent article “Smooth and simple page transitions with the shared element transition API” by Jake Archibald is the best place to learn more about it.

It’s currently behind the chrome://flags/#document-transition flag in Chrome 104+. You can also experiment with it in production via the origin trial.

Implementation

The logic of our application goes like this:

  1. Show a skeleton screen. It is a static card + loader with all the styles being inlined in the index.html document.
  2. Loading of the main application bundle starts at the bottom of index.html.
  3. After the main application web component finishes loading, the login form starts loading. This is done because the code of the application is shared, but the form is context-dependent.
  4. After the form is loaded and initiated, the special function _handleFinishLoading is called.
  5. This function stops the throbber and hides the skeleton screen covering the main application.
  6. The hiding is done in __removePreloader function, and this one is the most important for us for now.

According to the Shared Element Transition API docs, we change it to:

The first attempt, right from the documentation

Let’s run this code:

Result of the first attempt

Surprisingly, that immediately worked. However, this is by far not what we want, and the animation is not really visible because of the jumping loading of the elements.

The first observation is that the fonts, the form, or the notification can be rendered with a slight delay. So to align this, let’s just add a timeout (only for development!) to be sure that all elements are there and ready.

And voila:

Result of improved first attempt

This looks better! You can see the cross-fade animation of the card in the right side.

How Shared Element Transition API works

Chrome does the following for you:

  1. It makes a screenshot/render of the current page. In our case the skeleton screen.
  2. It makes a screenshot/render of the following page (after the callback function execution of transition.start). In our case, the card with authentication form of the application.
  3. It applies cross-fade, shape and position animations from the current page to the following page.

On top of already a nice job from Chrome, it gives an API to control how the transition to the following page should be done. We can use custom CSS animations for the following (incoming) image. The DOM tree of this API looks like this.

::page-transition
└─ ::page-transition-container(root)
└─ ::page-transition-image-wrapper(root)
├─ ::page-transition-outgoing-image(root)
└─ ::page-transition-incoming-image(root)

Apart from this, we can also define regions of the animation to connect “logically” the same elements between the screens.

Let’s try to define a region “card” and then animate it.

Here is the result of it

Attempt 2: card animation. A little bit slowed down

That looks closer to a lovely animation, but after a closer look, we can see that the incoming card gets a bit smaller first (and that’s not a bug of the video). After some investigation, I learned that box-shadow is not included in the calculations of the incoming card’s picture. This is probably because of how contain: paint works, but I consider it a bug.

To fix this, we can wrap our card with a box-shadow to another element and apply the transition to it.

We added a wrapper div to our cards and applied transition to it
No more jumping at the beginning of the animation. Looks decent

Let’s compare what we had in the very beginning and what we have now.

4 times slower to see the details

Considering that I needed to add in total:

  • 9 lines of JS
  • 9 lines of CSS
  • 4 lines of HTML

that looks impressive. I’m glad this standard was created, and I look forward to implementing it in all browsers.

Highly recommend (yes, again) checking the article by Jake Archibald. It has many more details and examples if you want to try it out.

  1. Careful review by Maarten Stolte.
  2. Careful review by Remco Gubbels.
  3. Careful review by Gabi Wesselman.

--

--