Modern Web Weekly #6

Modern Web Weekly #6
Staying up to date with the modern web

View Transitions: the final frontier for web apps?

One of the main differences between native apps and web apps is that native apps often have all kinds of fancy transitions between screens while web apps are stuck with abrupt page changes.

While browsers nowadays have a feature called "paint holding", which keeps the old page visible while the new one loads, you may still experience a flickering white screen between page changes. You can of course create a single-page app and implement the page transitions yourself, but this is really hard and error-prone.

Trust me, been there, done that.

The View Transition API aims to change this and bring native app-like transitions to the web, not only for single-page applications but also for multi-page applications in the future. This means that for now, you will still need a single-page app but your page transitions can be radically simplified, especially when combined with another new feature: the Navigation API.

Standardized client-site routing

Until now, it was only possible to implement client-side routing using the History API which is not impossible, but far from straightforward and requires a lot of edge cases to be covered. Since this API was never meant to handle client-side routing, a new API was introduced.

The Navigation API simplifies routing by centralizing all navigations that take place in your app (clicking a link, submitting a form, going back and forward, and programmatic navigation). It does this by firing a navigate event on the global navigation object.

To use the Navigation API, attach an event listener for this navigate event and handle the navigation by calling the NavigationEvent's intercept method:

navigation.addEventListener('navigate', e => {
  const url = new URL(e.destination.url);

  e.intercept({
    async handler() {
      const newPageContent = await getPageContent(url.pathname);
      renderPage(newPageContent);
    }
  });
});

The intercept method takes an object with a handler property which is a function that updates the DOM in any way you want. The API will also take care of updating the URL just like the History API.

This is, in a nutshell, how the Navigation API works.

🔗
Jake Archibald wrote a great article that explains the Navigation API in great detail. Make sure to read it. The link to the article is at the end of this newsletter.

Why we need View Transitions

As I mentioned, creating page transitions with the tools we currently have is really hard, even for simple transitions. Both pages need to be present in the DOM while transitioning which requires quite complex JavaScript and CSS.

In contrast, View Transitions are a remarkably simple concept. It captures snapshots of the old and new pages as pseudo-elements that can be animated with CSS animations and transitions. To capture the snapshots, call document.startViewTransition(callback).

This will first capture the old state of the page which included creating a screenshot of the old page. After that, the callback passed to startViewTransition() is called where the DOM is changed. Then, the new state of the page is captured which will create the following pseudo-element tree:

::view-transition
└─ ::view-transition-group(root)
   └─ ::view-transition-image-pair(root)
      ├─ ::view-transition-old(root)
      └─ ::view-transition-new(root)

where the screenshot of the old view is captured as ::view-transition-old(root) and a live presentation of the new view as ::view-transition-new(root). In this example, root means the transition applies to the entire page. You can, however, animate multiple elements in the page independently by giving each one a unique name through the view-transition-name property:

header {
  view-transition-name: main-header;
}

This will now create a second ::view-transition-group that can be animated independently:

::view-transition
├─ ::view-transition-group(root)
│  └─ ::view-transition-image-pair(root)
│     ├─ ::view-transition-old(root)
│     └─ ::view-transition-new(root)
└─ ::view-transition-group(main-header)
   └─ ::view-transition-image-pair(main-header)
      ├─ ::view-transition-old(main-header)
      └─ ::view-transition-new(main-header)

Customizing the transition

You can combine View Transitions with the Navigation API by adding document.startViewTransition() to the navigate event handler from the previous example:

navigation.addEventListener('navigate', e => {
  const url = new URL(e.destination.url);

  e.intercept({
    async handler() {
      const newPageContent = await getPageContent(url.pathname);
      
      // the renderPage() function that updates the DOM is now passed
      // as a callback to startViewTransition()  
      document.startViewTransition(() => renderPage(newPageContent));  
    }
  });
});

If you don't define any CSS for ::view-transition-old(root) and ::view-transition-new(root) the default view transition will be a cross-fade because by default ::view-transition-old(root) will transition from opacity: 1 to opacity: 0 and ::view-transition-new(root) will transition from opacity: 0 to opacity: 1.

Let's add some CSS to create a transition like this:

This is a common pattern where the old page slides out to the left for about a fifth of its width and the new page slides in all the way from the right to the left. When the user navigates back the current page will slide out to the right revealing the previous page under it which will slide back to the right until it's back in the viewport.

Let's start with the CSS that is shared between the old and the new page for the animations:

::view-transition-new(root),
::view-transition-old(root) {
  animation-duration: 300ms;
  animation-timing-function: cubic-bezier(0.465, 0.183, 0.153, 0.946);
  animation-direction: normal;
}

Then we need to define the animations for the old and new pages and add them:

// animation for old page
@keyframes slide-out {
  from {
    transform: translateX(0);
  }
  to {
    transform: translateX(-20%);
  }
}

// animation for new page
@keyframes slide-in {
  from {
    transform: translateX(100%);
  }
  to {
    transform: translateX(0);
  }
}

// add the animation for the old page
::view-transition-old(root) {
  animation-name: slide-out;
}

// add the animation for the new page
::view-transition-new(root) {
  animation-name: slide-in;
  mix-blend-mode: normal;
}

We specify the animation's name for the pages to ensure they correctly animate. To the new page, we need to add mix-blend-mode: normal to make sure it displays on top of the old page so it slides over it.

With this CSS in place, we have correct animations for forward animation but now when we navigate back, the previous page will animate as if it was a new page, which means that the old page will now slide in from the right instead of sliding back from the left:

We can solve this by adding a class (for example back-navigation) to the <html> element when a back navigation is detected and define other animations for the old and new pages. This means we need to define the animations for forward navigation in reverse and then only apply these when the class back-navigation is present:

// animation for previous page that now becomes the new page
// when back navigating, this slides the page back in from the right
@keyframes slide-out-reverse {
  from {
    transform: translateX(-20%);
  }
  to {
    transform: translateX(0);
  }
}

// animation for the new page that now becomes the old page, this 
// slides the page out all the way to the left
@keyframes slide-in-reverse {
  from {
    transform: translateX(0);
  }
  to {
    transform: translateX(100%);
  }
}

// animation for the new page only when navigating back
.back-transition::view-transition-new(root) {
  animation-name: slide-out-reverse;
}

// animation for the old page only when navigating back
.back-transition::view-transition-old(root) {
  animation-name: slide-in-reverse;
  mix-blend-mode: normal;
  z-index: 1;
}

To make sure that the old page stays on top of the new one when it slides out to the right we again need mix-blend-mode: normal but also a z-index value that's higher than the one of the old page (1 in this example).

Now we need to detect back navigations in the navigate event handler, add the class back-navigation when one is detected and remove it when the view transition is complete.

🔗
In this example, I borrowed the isBackNavigation function from Jake Archibald's great article on View Transitions. Make sure to read it, the link is at the end of this newsletter.
const isBackNavigation = (e) => {
  const {navigationType, destination} = e;
  if (navigationType === 'push' || navigationType === 'replace') {
    return false;
  }
  if (destination.index !== -1 &&
      destination.index < navigation.currentEntry.index) {
    return true;
  }
  return false;
};

navigation.addEventListener('navigate', e => {
  const url = new URL(e.destination.url);
    
  // detect back navigation
  const backNavigation = isBackNavigation(e);  
  
  // add the class "back-navigation" to <html> when a back navigation 
  // is detected
  if(backNavigation) {
    document.documentElement.classList.add('back-transition');
  }  

  e.intercept({
    async handler() {
      const newPageContent = await getPageContent(url.pathname);
      
      // return the `transition` object 
      const transition = document.startViewTransition(
        () => renderPage(newPageContent)
      );  
        
      try {
        // the returned `transition` object has a property `finished` 
        // which is a Promise that resolves when the transition 
        // is complete   
        await transition.finished;
      }
      finally {
        // remove the "back-transition" class  
        document.documentElement.classList.remove(
          'back-transition'
        );
      }  
    }
  });
});

Now back navigations also have correct transitions but we have two more issues related to scroll position. When the home page is scrolled down and we navigate to a new page, that page is opened at the same scroll position instead of at the top of the page. Then, when we navigate back, the home page quickly scrolls back to its previous scroll position:

What we want is all pages to be opened at the top for both forward and back navigations. We can solve this for the newly opened page by scrolling it to the top with window.scrollTo(0, 0).

This needs to be done inside the callback that is passed to document.startViewTransition() to make sure the new page is already scrolled to the top when it becomes visible. If you do this before or after the transition takes place the page will visibly jump.

The scrolling of the home page to the previous scroll position happens because the Navigation API will attempt to restore the scroll position by default. You can opt out of this by adding scroll: 'manual' to the object that is passed to the intercept method of the NavigateEvent:

navigation.addEventListener('navigate', e => {
  const url = new URL(e.destination.url);
    
  const backNavigation = isBackNavigation(e);  
  
  if(backNavigation) {
    document.documentElement.classList.add('back-transition');
  }  

  e.intercept({
    // makes sure home page opens scrolled to top on back navigation    
    scroll: 'manual', 
    async handler() {
      const newPageContent = await getPageContent(url.pathname);
      
      const transition = document.startViewTransition(() => {
        //makes sure new page is scrolled to top on forward navigation
        if(!backNavigation) {
          window.scrollTo(0, 0);
        }  
        renderPage(newPageContent);
      });  
          
        
      try {
        await transition.finished;
      }
      finally {
        document.documentElement.classList.remove(
          'back-transition'
        );
      }  
    }
  });
});

Now all animations are correct! 🎉

Here is a Glitch demo that you can edit and experiment with (open the web version of this email if it doesn't show):

View Transitions

Scroll down on the first page of the demo to click the link to page 2 to see that for both forward and back navigations the page is always correctly loaded at the top.

Note that the <footer> element has view-transition-name: main-footer in its CSS. This creates a separarate ::view-transition-group for it and since we didn't add any CSS to it for view transitions it will not be animated and will stay in place during the transitions. Nice!

So, how about multi-page apps?

The spec that makes View Transitions possible for multi-page apps is under heavy development but can already be tested in Chrome Canary. In the next edition of Modern Web Weekly I will explain View Transitions for MPAs.

Further reading

As I mentioned, Jake Archibald wrote two great articles on the Navigation API and View Transitions that were of incredible help to me to understand the APIs and to write this article. Make sure to read these!

Modern client-side routing: the Navigation API
Smooth and simple transitions with the View Transitions API

🔗
Got an interesting link for Modern Web Weekly?Send me a DM on Twitter to let me know!