Cross-document view transitions for multi-page applications

When a view transition occurs between two different documents it is called a cross-document view transition. This is typically the case in multi-page applications (MPA). Cross-document view transitions are supported in Chrome from Chrome 126.

Browser Support

  • Chrome: 126.
  • Edge: 126.
  • Firefox: not supported.
  • Safari: not supported.

Cross-document view transitions rely on the very same building blocks and principles as same-document view transitions, which is very intentional:

  1. The browser takes snapshots of elements that have a unique view-transition-name on both the old and new page.
  2. The DOM gets updated while rendering is suppressed.
  3. And finally, the transitions are powered by CSS animations.

What's different when compared with same-document view transitions, is that with cross-document view transitions you don't need to call document.startViewTransition to start a view transition. Instead, the trigger for a cross-document view transition is a same-origin navigation from one page to another, an action that is typically performed by the user of your website clicking a link.

In other words, there is no API to call in order to start a view transition between two documents. However, there are two conditions that need to be fulfilled:

  • Both documents need to exist on the same origin.
  • Both pages need to opt-in to allow the view transition.

Both these conditions are explained later in this document.


Cross-document view transitions are limited to same-origin navigations

Cross-document view transitions are limited to same-origin navigations only. A navigation is considered to be same-origin if the origin of both participating pages is the same.

The origin of a page is a combination of the used scheme, hostname, and port, as detailed on web.dev.

An example URL with the scheme, hostname, and port highlighted. Combined, they form the origin.
An example URL with the scheme, hostname, and port highlighted. Combined, they form the origin.

For example, you can have a cross-document view transition when navigating from developer.chrome.com to developer.chrome.com/blog, as those are same-origin. You can't have that transition when navigating from developer.chrome.com to www.chrome.com, as those are cross-origin and same-site.


Cross-document view transitions are opt-in

To have a cross-document view transition between two documents, both participating pages need to opt-in to allowing this. This is done with the @view-transition at-rule in CSS.

In the @view-transition at-rule, set the navigation descriptor to auto to enable view transitions for cross-document, same-origin navigations.

@view-transition {
  navigation: auto;
}

By setting navigation descriptor to auto you are opting in to allowing view transitions to happen for the following NavigationTypes:

  • traverse
  • push or replace, if the activation was not initiated by the user through browser UI mechanisms.

Navigations excluded from auto are, for example, navigating using the URL address bar or clicking a bookmark, as well as any form of user or script initiated reload.

If a navigation takes too long–more than four seconds in Chrome's case–then the view transition is skipped with a TimeoutError DOMException.

Cross-document view transitions demo

Check out the following demo that uses view transitions to create a Stack Navigator demo. There are no calls to document.startViewTransition() here, the view transitions are triggered by navigating from one page to another.

Recording of the Stack Navigator demo. Requires Chrome 126+.

Customize cross-document view transitions

To customize cross-document view transitions, there are some web platform features that you can use.

These features are not part of the View Transition API specification itself, but are designed to be used in conjunction with it.

The pageswap and pagereveal events

Browser Support

  • Chrome: 124.
  • Edge: 124.
  • Firefox: not supported.
  • Safari: not supported.

Source

To allow you to customize cross-document view transitions, the HTML specification includes two new events that you can use: pageswap and pagereveal.

These two events get fired for every same-origin cross-document navigation regardless of whether a view transition is about to happen or not. If a view transition is about to happen between the two pages, you can access the ViewTransition object using the viewTransition property on these events.

  • The pageswap event fires before the last frame of a page is rendered. You can use this to do some last-minute changes on the outgoing page, right before the old snapshots get taken.
  • The pagereveal event fires on a page after it has been initialized or reactivated but before the first rendering opportunity. With it, you can customize the new page before the new snapshots get taken.

For example, you can use these events to quickly set or change some view-transition-name values or pass data from one document to another by writing and reading data from sessionStorage to customize the view transition before it actually runs.

let lastClickX, lastClickY;
document.addEventListener('click', (event) => {
  if (event.target.tagName.toLowerCase() === 'a') return;
  lastClickX = event.clientX;
  lastClickY = event.clientY;
});

// Write position to storage on old page
window.addEventListener('pageswap', (event) => {
  if (event.viewTransition && lastClick) {
    sessionStorage.setItem('lastClickX', lastClickX);
    sessionStorage.setItem('lastClickY', lastClickY);
  }
});

// Read position from storage on new page
window.addEventListener('pagereveal', (event) => {
  if (event.viewTransition) {
    lastClickX = sessionStorage.getItem('lastClickX');
    lastClickY = sessionStorage.getItem('lastClickY');
  }
});

If you want, you can decide to skip the transition in both events.

window.addEventListener("pagereveal", async (e) => {
  if (e.viewTransition) {
    if (goodReasonToSkipTheViewTransition()) {
      e.viewTransition.skipTransition();
    }
  }
}

The ViewTransition object in pageswap and pagereveal are two different objects. They also handle the various promises differently:

  • pageswap: Once the document is hidden, the old ViewTransition object is skipped. When that happens, viewTransition.ready rejects and viewTransition.finished resolves.
  • pagereveal: The updateCallBack promise is already resolved at this point. You can use the viewTransition.ready and viewTransition.finished promises.

Browser Support

  • Chrome: 123.
  • Edge: 123.
  • Firefox: not supported.
  • Safari: not supported.

Source

In both pageswap and pagereveal events, you can also take action based on the URLs of the old and new pages.

For example, in the MPA Stack Navigator the type of animation to use depends the navigation path:

  • When navigating from the overview page to a detail page, the new content needs to slide in from the right to the left.
  • When navigating from the detail page to the overview page, the old content needs to slide out from the left to the right.

To do this you need information about the navigation that, in the case of pageswap, is about to happen or, in the case of pagereveal just happened.

For this, browsers can now expose NavigationActivation objects which hold info about the same-origin navigation. This object exposes the used navigation type, the current, and the final destination history entries as found in navigation.entries() from the Navigation API.

On an activated page, you can access this object through navigation.activation. In the pageswap event, you can access this through e.activation.

Check out this Profiles demo that uses NavigationActivation info in the pageswap and pagereveal events to set the view-transition-name values on the elements that need to participate in the view transition.

That way, you don't have to decorate each and every item in the list with a view-transition-name upfront. Instead, this happens just-in-time using JavaScript, only on elements that need it.

Recording of the Profiles demo. Requires Chrome 126+.

The code is as follows:

// OLD PAGE LOGIC
window.addEventListener('pageswap', async (e) => {
  if (e.viewTransition) {
    const targetUrl = new URL(e.activation.entry.url);

    // Navigating to a profile page
    if (isProfilePage(targetUrl)) {
      const profile = extractProfileNameFromUrl(targetUrl);

      // Set view-transition-name values on the clicked row
      document.querySelector(`#${profile} span`).style.viewTransitionName = 'name';
      document.querySelector(`#${profile} img`).style.viewTransitionName = 'avatar';

      // Remove view-transition-names after snapshots have been taken
      // (this to deal with BFCache)
      await e.viewTransition.finished;
      document.querySelector(`#${profile} span`).style.viewTransitionName = 'none';
      document.querySelector(`#${profile} img`).style.viewTransitionName = 'none';
    }
  }
});

// NEW PAGE LOGIC
window.addEventListener('pagereveal', async (e) => {
  if (e.viewTransition) {
    const fromURL = new URL(navigation.activation.from.url);
    const currentURL = new URL(navigation.activation.entry.url);

    // Navigating from a profile page back to the homepage
    if (isProfilePage(fromURL) && isHomePage(currentURL)) {
      const profile = extractProfileNameFromUrl(currentURL);

      // Set view-transition-name values on the elements in the list
      document.querySelector(`#${profile} span`).style.viewTransitionName = 'name';
      document.querySelector(`#${profile} img`).style.viewTransitionName = 'avatar';

      // Remove names after snapshots have been taken
      // so that we're ready for the next navigation
      await e.viewTransition.ready;
      document.querySelector(`#${profile} span`).style.viewTransitionName = 'none';
      document.querySelector(`#${profile} img`).style.viewTransitionName = 'none';
    }
  }
});

The code also cleans up after itself by removing the view-transition-name values after the view transition ran. This way the page is ready for successive navigations and can also handle traversal of the history.

To aid with this, use this utility function that temporarily sets view-transition-names.

const setTemporaryViewTransitionNames = async (entries, vtPromise) => {
  for (const [$el, name] of entries) {
    $el.style.viewTransitionName = name;
  }

  await vtPromise;

  for (const [$el, name] of entries) {
    $el.style.viewTransitionName = '';
  }
}

The previous code can now be simplified as follows:

// OLD PAGE LOGIC
window.addEventListener('pageswap', async (e) => {
  if (e.viewTransition) {
    const targetUrl = new URL(e.activation.entry.url);

    // Navigating to a profile page
    if (isProfilePage(targetUrl)) {
      const profile = extractProfileNameFromUrl(targetUrl);

      // Set view-transition-name values on the clicked row
      // Clean up after the page got replaced
      setTemporaryViewTransitionNames([
        [document.querySelector(`#${profile} span`), 'name'],
        [document.querySelector(`#${profile} img`), 'avatar'],
      ], e.viewTransition.finished);
    }
  }
});

// NEW PAGE LOGIC
window.addEventListener('pagereveal', async (e) => {
  if (e.viewTransition) {
    const fromURL = new URL(navigation.activation.from.url);
    const currentURL = new URL(navigation.activation.entry.url);

    // Navigating from a profile page back to the homepage
    if (isProfilePage(fromURL) && isHomePage(currentURL)) {
      const profile = extractProfileNameFromUrl(currentURL);

      // Set view-transition-name values on the elements in the list
      // Clean up after the snapshots have been taken
      setTemporaryViewTransitionNames([
        [document.querySelector(`#${profile} span`), 'name'],
        [document.querySelector(`#${profile} img`), 'avatar'],
      ], e.viewTransition.ready);
    }
  }
});

Wait for content to load with render blocking

Browser Support

  • Chrome: 124.
  • Edge: 124.
  • Firefox: not supported.
  • Safari: not supported.

In some cases, you may want to hold off the first render of a page until a certain element is present in the new DOM. This avoids flashing and ensure the state you're animating to is stable.

In the <head>, define one or more element IDs that need to be present before the page gets its first render, using the following meta tag.

<link rel="expect" blocking="render" href="#section1">

This meta tag means that the element should be present in the DOM, not that the content should be loaded. For example with images, the mere presence of the <img> tag with the specified id in the DOM tree is enough for the condition to evaluate to true. The image itself could still be loading.

Before you go all-in on render blocking be aware that incremental rendering is a fundamental aspect of the Web, so be cautious when opting to blocking rendering. The impact of blocking rendering needs to be evaluated on a case by case basis. By default, avoid using blocking=render unless you can actively measure and gauge the impact it has on your users, by measuring the impact to your Core Web Vitals.


View transition types in cross-document view transitions

Cross-document view transitions also support view transition types to customize the animations and which elements get captured.

For example, when going to the next or to the previous page in a pagination, you might want to use different animations depending on whether you are going to a higher page or a lower page from the sequence.

To set these types upfront, add the types in the @view-transition at-rule:

@view-transition {
  navigation: auto;
  types: slide, forwards;
}

To set the types on the fly, use the pageswap and pagereveal events to manipulate the value of e.viewTransition.types.

window.addEventListener("pagereveal", async (e) => {
  if (e.viewTransition) {
    const transitionType = determineTransitionType(navigation.activation.from, navigation.activation.entry);
    e.viewTransition.types.add(transitionType);
  }
});

The types are not automatically carried over from the ViewTransition object on the old page to the ViewTransition object of the new page. You need to determine the type(s) to use on at least the new page in order for the animations to run as expected.

To respond to these types, use the :active-view-transition-type() pseudo-class selector in the same way as with same-document view transitions

/* Determine what gets captured when the type is forwards or backwards */
html:active-view-transition-type(forwards, backwards) {
  :root {
    view-transition-name: none;
  }
  article {
    view-transition-name: content;
  }
  .pagination {
    view-transition-name: pagination;
  }
}

/* Animation styles for forwards type only */
html:active-view-transition-type(forwards) {
  &::view-transition-old(content) {
    animation-name: slide-out-to-left;
  }
  &::view-transition-new(content) {
    animation-name: slide-in-from-right;
  }
}

/* Animation styles for backwards type only */
html:active-view-transition-type(backwards) {
  &::view-transition-old(content) {
    animation-name: slide-out-to-right;
  }
  &::view-transition-new(content) {
    animation-name: slide-in-from-left;
  }
}

/* Animation styles for reload type only */
html:active-view-transition-type(reload) {
  &::view-transition-old(root) {
    animation-name: fade-out, scale-down;
  }
  &::view-transition-new(root) {
    animation-delay: 0.25s;
    animation-name: fade-in, scale-up;
  }
}

Because types only apply to an active view transition, types automatically get cleaned up when a view transition finishes. Because of that, types work well with features like BFCache.

Demo

In the following pagination demo, the page contents slide forwards or backwards based on the page number that you are navigating to.

Recording of the Pagination demo (MPA). It uses different transitions depending on which page you are going to.

The transition type to use is determined in the pagereveal and pageswap events by looking at the to and from URLs.

const determineTransitionType = (fromNavigationEntry, toNavigationEntry) => {
  const currentURL = new URL(fromNavigationEntry.url);
  const destinationURL = new URL(toNavigationEntry.url);

  const currentPathname = currentURL.pathname;
  const destinationPathname = destinationURL.pathname;

  if (currentPathname === destinationPathname) {
    return "reload";
  } else {
    const currentPageIndex = extractPageIndexFromPath(currentPathname);
    const destinationPageIndex = extractPageIndexFromPath(destinationPathname);

    if (currentPageIndex > destinationPageIndex) {
      return 'backwards';
    }
    if (currentPageIndex < destinationPageIndex) {
      return 'forwards';
    }

    return 'unknown';
  }
};

Feedback

Developer feedback is always appreciated. To share, file an issue with the CSS Working Group on GitHub with suggestions and questions. Prefix your issue with [css-view-transitions]. Should you run into a bug, then file a Chromium bug instead.