DEV Community

Cover image for How to implement view transitions in multi-page apps
Megan Lee for LogRocket

Posted on • Originally published at blog.logrocket.com

How to implement view transitions in multi-page apps

Written by Rob O'Leary✏️

The View Transition API brings page transitions and state-based UI changes — previously only possible with JavaScript frameworks — to the wider web.

This includes animating between DOM states in a single-page app (SPA) and animating the navigation between pages in a multi-page app (MPA). In other words, it brings view transitions to any type of website without bulky JavaScript dependencies and heady complexity. This is a win for users and developers! It is a game changer potentially.

In this article, I will focus on view transitions in MPAs. This is defined in the CSS View Transitions Module Level 2 specification and is referred to as cross-document view transitions. The cool thing is that the basics can be achieved without JavaScript — just a bit of declarative CSS will get you up and running! JavaScript is required when you want to implement some conditional logic.

Cross-document view transitions are now supported in both Chrome 126 and Safari 18.2. We can dive in straight away and use view transitions as a progressive enhancement today! 🙌

Why should you use view transitions?

View transitions improve the navigation experience. More specifically, they can:

  • Help users maintain their current context, reducing cognitive load by drawing attention to changes and guiding users on their journey
  • Reduce the perceived loading latency when switching between states/pages
  • Animations generally add a polish and feel for a more engaging website

How does a view transition work?

All view transitions involve the following three steps:

  • The browser takes snapshots of the old state and the new state. The old state is a static snapshot, like a screenshot. The new state is a live representation of the document
  • The DOM is updated without rendering taking place
  • The transition to the new state is done via CSS animations. The default transition is a crossfade. The old view animates from opacity: 1 to opacity: 0 while the new view animates from opacity: 0 to opacity: 1

The major difference between a view transition in a SPA and an MPA is how the transition is triggered. In an MPA, a view transition is triggered by navigating to another page — this can happen by clicking on a link or submitting a form. Navigations that don’t trigger a view transition include navigating using the URL address bar, clicking a bookmark, and reloading a page.

If a navigation takes too long, then the view transition is skipped, resulting in an error. Chrome's limit is four seconds. It’s unclear what defines the start of a navigation in this context — is it that initial bytes have to be downloaded?

Interestingly, a single page can have multiple view transitions. We can target specific subtrees of the DOM for transitions, and it’s even possible to nest them.

There are some conditions for enabling view transitions, which we’ll cover in the next section.

Creating a basic view transition

To enable view transitions for a website, two key conditions need to be met:

  1. The source and target pages must have the same origin: The URLs must have the same scheme, domain, and port
  2. Both pages must opt in to view transitions: This is done through the [@view-transition](https://developer.mozilla.org/en-US/docs/Web/CSS/@view-transition) CSS at-rule, as shown below:
@view-transition {
  navigation: auto;
}
Enter fullscreen mode Exit fullscreen mode

With that, the default crossfade view transition should be enabled for the pages. Let's look at an example.

Here is a demo of a carousel featuring a set of photos. A carousel is a component that permits cycling through a set of content, such as photos in a photo gallery. In this example, I slowed down the animation to two seconds to highlight the effect (more on this later):

Take a look at the example here.

View transitions allow us to create a carousel where each page is an item. Links to the next page and the previous page are all that is necessary in the CSS snippet above. There is no need to juggle items with code or to stuff a ton of images into a single page:

<!-- index.html - first page-->
<h1>Cape Town</h1>
<img src="cape-town.webp" alt=".."/>
<a href="page2.html" class="next"><img src="/1-carousel/shared/img/arrow-right.svg" /></a>

<!-- page2.html - second page-->
<h1>Hong Kong</h1>
<img src="hong-kong.webp" alt=".."/>
<a href="index.html" class="previous"><img src="/1-carousel/shared/img/arrow-left.svg" /></a>
<a href="page3.html" class="next"><img src="/1-carousel/shared/img/arrow-right.svg" /></a> 
Enter fullscreen mode Exit fullscreen mode

Here is an overview figure of the pages involved so you can better understand what is happening:

Overview Of The Page Architecture

It would be remiss of me not to mention that there are three other conditions for enabling view transitions. You are likely satisfying these conditions by default.

The fine print of the specification states that all of these conditions must be met:

  • Both pages must opt in to view transitions
  • The source and target pages must have the same origin
  • The page must be visible throughout the entire course of the navigation
  • The navigation must be initiated by the page e.g., by clicking a link, submitting a form, or is a traverse navigation (back/forward)
  • The navigation must not include cross-origin redirects

Customizing the animation of a view transition

We can customize the animation through pseudo-elements:

  1. [::view-transition-group()](https://developer.mozilla.org/en-US/docs/Web/CSS/::view-transition-group) is used to reference a particular view transition
  2. [::view-transition-old()](https://developer.mozilla.org/en-US/docs/Web/CSS/::view-transition-old) is used to reference the source view (outbound transition)
  3. [::view-transition-new()](https://developer.mozilla.org/en-US/docs/Web/CSS/::view-transition-new) is used to reference the target view (inbound transition)

For each of these pseudo-elements, we provide the view transition name as an argument to reference the view transition we are interested in. The default name for the view transition of a page is root as it applies to the :root element.

You can change the duration of the animation by putting the following on both pages:

::view-transition-group(root) {
  animation-duration: 3s;
}
Enter fullscreen mode Exit fullscreen mode

To create a different transition effect, we can set animations for the source (old) and target (new) views separately.

For example, let's make a demo with a shrink animation. We will shrink the source page out of view, and have the target page expand into view, like so.

We can do that with the following CSS:

@keyframes shrink {
  to {
    scale: 0;
  }
}

::view-transition-old(root) {
  animation: shrink 1s;
}

::view-transition-new(root) {
  animation: shrink 1s;
  animation-direction: reverse;
}
Enter fullscreen mode Exit fullscreen mode

Notice that we set animation-direction: reverse on the target view transition; this makes the "shrink" animation expand!

Having opposite actions creates a symmetric effect, which can be pleasing. You don’t have to do this — you can treat each animation as a separate entity. You are free to come up with whatever tickles your fancy!

For cross-document view transitions, these pseudo-elements are only available on the target page. Don’t forget about this if you are making a view transition that goes in only one direction.

Let’s see what else we can do with our view transitions — this time, introducing JavaScript!

Customizing cross-document view transitions

So far, we have demonstrated that we can enable cross-document view transitions and customize the animations with CSS. This is powerful, but when we want to do more, we need JavaScript.

The View Transition API does not cover all our needs. There are some complementary web features designed to be used in conjunction with it. They fall into the following categories:

  • Lifecycle events: The [pageswap](https://developer.mozilla.org/en-US/docs/Web/API/Window/pageswap_event) and [pagereveal](https://developer.mozilla.org/en-US/docs/Web/API/Window/pagereveal_event) events enable specifying conditional actions for view transitions. The pageswap event is fired before the source page unloads, and the pagereveal is fired before rendering the target page
  • Navigation information: Browsers now expose [NavigationActivation](https://developer.mozilla.org/en-US/docs/Web/API/NavigationActivation) objects that hold information about same-origin navigations. This saves developers the hassle of keeping track of this information themselves when they want to perform different animations/actions based on different URLs
  • Declarative render-blocking: In some cases, you may want to hold off the rendering of the target page until a certain element is present. This ensures the state you're animating to is stable

We will discuss these features further with some examples.

Using pageswap and pagereveal events

The pageswap and pagereveal events give us the opportunity to perform some conditional logic for a view transition.

The pageswap event is fired at the last moment before the source page is about to be unloaded and swapped by the target page. It can be used to find out whether a view transition is about to take place, customize it using types, make last-minute changes to the captured elements, or skip the view transition entirely.

The pagereveal event is fired right before presenting the first frame of the target page. It can be used to act on different navigation types, make last-minute changes to the captured elements, wait for the transition to be ready in order to animate it, or skip it altogether.

In both of these events, you can access a [ViewTransition](https://developer.mozilla.org/en-US/docs/Web/API/ViewTransition) object using the ViewTransition property. The ViewTransition object represents an active view transition and provides functionality to react to the transition reaching different states e.g., when the animation is about to run, and when the animation has just finished.

Let's look at an example to tie the concepts together.

Demo: Allowing the user to disable/enable view transitions

Let's create a demo to allow the user to disable/enable view transitions. I will add a checkbox to our carousel in the top right corner. If it is checked, we will disable (skip) view transitions:

Implementing Option To Skip View Transitions

We need to modify our HTML to add our checkbox input, and we need to add a script tag to point to the script we are about to write. We must add the script tag as a parser-blocking script in the <head>. This is because the pagereveal event must execute before the first rendering opportunity. This means the script can’t be a module, can’t have the async attribute, and can’t have the defer attribute:

<!DOCTYPE html>
<html lang="en">
  <head>
    <!-- other elements as before-->

    <!-- our script must be here exactly like this-->
    <script src="script.js"></script>
  </head>

  <body>
    <label>Skip?<input type="checkbox" id="skip" /></label>

    <!-- other elements as before-->
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

In our script, we will add event handlers for the pageswap and pagereveal events. In the pageswap event handler, we write the value of the checkbox (true or false) to session storage, saving it as the skip variable.

Notice that I consult the ViewTransition object to decide if we want to store the value or not. The ViewTransition object is null if there is no view transition taking place. Therefore, this check will return true when a view transition is taking place.

In the pagereveal event handler, we read the value of the skip variable from session storage. If skip has a value of "true" (session storage saves all values as strings), then we skip the view transition by calling the ViewTransition.skipTransition() function:

/* script.js */

// Write to storage on old page
window.addEventListener("pageswap", (event) => {
  if (event.viewTransition) {
    let skipCheckbox = document.querySelector("#skip");
    sessionStorage.setItem("skip", skipCheckbox.checked);
  }
});

// Read from storage on new page
window.addEventListener("pagereveal", (event) => {
  if (event.viewTransition) {
    let skip = sessionStorage.getItem("skip");
    let skipCheckbox = document.querySelector("#skip");

    if (skip === "true") {
      event.viewTransition.skipTransition();
      skipCheckbox.checked = true;
    } else {
      skipCheckbox.checked = false;
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

We use the value from session storage in pagereveal to persist the checkbox state in the target page. This maintains the checkbox state between page navigations. Remember that HTTP is a stateless protocol; it will forget everything about the previous page unless you tell it!

If you are not familiar with session storage, you can inspect session storage in Chrome's DevTools. You will find it in the Application tab (as seen in the image below). On the sidebar under the Storage category, you will see a Session storage item. Click on it and you should see the origin of your website e.g., http://localhost:3000. Click on it and it will reveal all of the stored values:

The Application Tab In Chrome DevTools With The Session Storage Item Open That Is Contained Under The Storage Category In The Sidebar

The Application tab in Chrome DevTools with the Session storage item open that is contained under the Storage category in the sidebar

Navigation activation information

In the pageswap and pagereveal events, you can take actions based on the navigation that is taking place. This information is available through the NavigationActivation object. This object exposes the used navigation type, the source page navigation history entry, and the target page navigation history entry. It is through these navigation history entries that we can get the URL of each page. At the time of writing, only Chrome supports the NavigationActivation object.

Demo: Carousel slide animation

Let's make a demo to add a slide animation to our carousel. We want the following to happen:

  • When we click the next link, we slide the source view out to the left and the target view in from the right
  • When we click the previous link, we slide the source view out to the right and the target view in from the left.

See here for a demonstration.

For this scenario, you can use view transition types. You can assign one or more types to an active view transition through a Set object available in the ViewTransition.types property. For our example, when transitioning to a higher page in the sequence, we will assign the next type, and when going to a lower page we assign the previous type.

Each of the types can be referenced in CSS to assign different animations:

Overview Of The Assigning Of Types For Page Navigations. The Link Pointing To A Page Lower In The Sequence Are Assigned A Previous Type, And A Link Pointing To Page Higher In The Sequence Is Assigned A Next Type.

Overview of the assigning of types for page navigations. The link pointing to a page lower in the sequence is assigned a previous type, and a link pointing to a page higher in the sequence is assigned a next type.

Sounds good, right? But how do we determine the type?

It’s up to you to determine the type!

In this case, I will inspect the URL of the source page and target page to identify their order. We can get the URL of the source page and target page from the NavigationActivation object. It contains a from attribute that represents the source page as a history entry, and an entry attribute that represents the target page as a history entry.

Because we follow a naming convention for our files that indicates their order, we can use this to identify an index for each page. The order is as follows:

  1. index.html
  2. page2.html
  3. page3.html

In our code, our determineTransitionType function will compare the indexes of the source page and target page to determine if it is a previous type or next type:

window.addEventListener("pageswap", async (e) => {
  if (e.viewTransition) {
    let transitionType = determineTransitionType(
      e.activation.from.url,
      e.activation.entry.url
    );

    e.viewTransition.types.add(transitionType);
  }
});

window.addEventListener("pagereveal", async (e) => {
  if (e.viewTransition) {
    // pagereveal does not expose the NavigationActivation object, we must get it from the global object
    let transitionType = determineTransitionType(
      navigation.activation.from.url,
      navigation.activation.entry.url
    );

    e.viewTransition.types.add(transitionType);
  }
});

function determineTransitionType(sourceURL, targetURL) {
  const sourcePageIndex = getIndex(sourceURL);
  const targetPageIndex = getIndex(targetURL);

  if (sourcePageIndex > targetPageIndex) {
    return "previous";
  } else if (sourcePageIndex < targetPageIndex) {
    return "next";
  }

  return "unknown";
}

function getIndex(url) {
  let index = -1;
  let filename = new URL(url).pathname.split("/").pop();

  if (filename === "index.html") {
    index = 1;
  }

  // extract a number from the filename
  let numberMatches = /\d+/g.exec(filename);
  if (numberMatches && numberMatches.length === 1) {
    index = numberMatches[0];
  }

  return index;
}
Enter fullscreen mode Exit fullscreen mode

In our stylesheet, we specify the four animations required. I was quite literal with their names:

@keyframes slide-in-from-left {
  from {
    translate: -100vw 0;
  }
}

@keyframes slide-in-from-right {
  from {
    translate: 100vw 0;
  }
}

@keyframes slide-out-to-left {
  to {
    translate: -100vw 0;
  }
}

@keyframes slide-out-to-right {
  to {
    translate: 100vw 0;
  }
}
Enter fullscreen mode Exit fullscreen mode

We associate the animations with the view transition type with the :active-view-transition-type() pseudo-class, and we provide the type as an argument.

For each type, we specify the animation for the source page with ::view-transition-old() and the target page with ::view-transition-new():

::view-transition-group(root) {
  animation-duration: 400ms;
}

html:active-view-transition-type(next) {
  &::view-transition-old(root) {
    animation-name: slide-out-to-left;
  }

  &::view-transition-new(root) {
    animation-name: slide-in-from-right;
  }
}

html:active-view-transition-type(previous) {
  &::view-transition-old(root) {
    animation-name: slide-out-to-right;
  }

  &::view-transition-new(root) {
    animation-name: slide-in-from-left;
  }
}
Enter fullscreen mode Exit fullscreen mode

It takes a bit to get your head around all of this! But when you get used to it, you’ll be able to pull off a diverse range of animations for cross-document view transitions. That's an exciting prospect!

Declarative render-blocking

In some cases, you may want to hold off the rendering of the target page until a certain element is present. This ensures the state you're animating is stable:

<link rel="expect" blocking="render" href="#sidebar">
Enter fullscreen mode Exit fullscreen mode

This ensures that the element is present in the DOM, however, it doesn’t wait until the content is fully loaded. If you are using this feature with images or videos that may take longer to load, you should factor this in.

Use this feature wisely. Generally, we want to avoid blocking rendering! In my exploration of cross-document view transitions, I did not find a use case for this but it is good to be aware of its existence!

Browser support for the View Transition API

The browser support is strong for view transitions with both Chrome and Safari covering the majority of the APIs involved:

Feature Chrome Safari
Cross-Document View Transitions v126+ v18.2+
[View transition types](https://caniuse.com/mdn-api_viewtransition_types) v125+ v18.2+
[`PageRevealEvent`](https://caniuse.com/mdn-api_pagerevealevent) v123+ v18.2+
[`PageSwapEvent`](https://caniuse.com/mdn-api_pageswapevent) v124+ v18.2+
[`NavigationActivation`](https://caniuse.com/mdn-api_navigationactivation) [interface](https://caniuse.com/mdn-api_navigationactivation) v123+ -
Render blocking v124+ -
Nested View Transition Groups Enable with #enable-experimental-web-platform-features flag v18.2+
Auto View Transition Naming Behind #enable-experimental-web-platform-features flag v18.2+

Accessibility considerations with the View Transition API

No matter how cool an animation looks, it can cause issues for people with vestibular disorders. For those users, you can choose to slow the animation down, pick a more subtle animation, or stop the animation altogether. We can use the prefers-reduced-motion media query to achieve this.

The easiest way is to enable view transitions only for people who have no preference for reduced motion. For people with the preference, it is disabled by default:

/* Enable view transitions for everyone except those who prefer reduced motion */
@media (prefers-reduced-motion: no-preference) {
  @view-transition {
    navigation: auto;
  }
}
Enter fullscreen mode Exit fullscreen mode

Recommendations on your development environment

When working with cross-document view transitions, be careful if you are working with a hot reload development server. If pages are getting cached or the page is not being fully reloaded, then you may not see your changes reflected.

I found the easiest way to ensure caching is not taking place is to have the dev tools open and select the Disable cache checkbox on the Network tab:

The Network Tab Of Chome DevTools With The Disable Cache Checkbox Enabled

Also, if you are used to debugging using console.log() or similar, this is not effective when you are working across two pages. With every navigation, the console log will be cleared. It is better to use sessionStorage for logging if this is your preferred debugging method.

Demos

All of the demos I covered in this article can be found in this GitHub repo. I also included some of the demos prepared by the Chrome DevRel team.

Here are links to the live pages of the demos mentioned:

Navigation between pages must be within four seconds to see the view transitions.

Conclusion

The ability to add page transitions and state-based UI changes to any website is a significant step forward. Being able to apply view transitions without bulky JavaScript dependencies and the heady complexity of frameworks is good for users and developers! You can get up and running with some straightforward CSS. If you need conditional logic for a view transition, then you need to write some JavaScript code.

Generally, I've been impressed by the capability. However, I must admit that I struggled to understand aspects of using cross-document view transitions. The relationship between the View Transition API and companion APIs such as NavigationActivation was not apparent from the explanations I read. Once you get over those comprehension hurdles, then you can write effective view transitions with JavaScript code of a moderate length.

The browser support is strong for the APIs related to view transitions with both Chrome and Safari covering the majority of them. In any case, you can use view transitions as a progressive enhancement. Be mindful that it is a new web feature, so you may stumble upon some issues.

It is also important to understand that cross-page view transitions require fast-loading pages. If a navigation takes over four seconds, Chrome will bail on the view transition. A complementary web feature that you can use to speed up navigation is prerendering. The Speculation Rules API is designed to improve performance for future navigations. These features point towards a faster and more capable web, but it will take people to build websites in a new fashion to realize the benefits.

The capabilities of view transitions are also expanding. Nested view transitions have been added recently and some experimental additions are being explored. The Chrome DevRel team has stated that they want to add more options for the conditions of navigation, maybe even permit cross-origin view transitions!

Give view cross-document view transitions a try!

References


Is your frontend hogging your users' CPU?

As web frontends get increasingly complex, resource-greedy features demand more and more from the browser. If you’re interested in monitoring and tracking client-side CPU usage, memory usage, and more for all of your users in production, try LogRocket.

LogRocket Signup

LogRocket is like a DVR for web and mobile apps, recording everything that happens in your web app, mobile app, or website. Instead of guessing why problems happen, you can aggregate and report on key frontend performance metrics, replay user sessions along with application state, log network requests, and automatically surface all errors.

Modernize how you debug web and mobile apps — start monitoring for free.

Top comments (1)

Collapse
 
martrapp profile image
Martin Trapp

Hi Rob! Thanks so much for this well-researched post. I really appreciate the depth of knowledge and the clear way you’ve laid everything out to explain the current state from A to Z. Great work!