Same-document view transitions for single-page applications

Published: Aug 17, 2021, Last updated: Sep 25, 2024

When a view transition runs on a single document it is called a same-document view transition. This is typically the case in single-page applications (SPAs) where JavaScript is used to update the DOM. Same-document view transitions are supported in Chrome as of Chrome 111.

To trigger a same-document view transition, call document.startViewTransition:

function handleClick(e) {
  // Fallback for browsers that don't support this API:
  if (!document.startViewTransition) {
    updateTheDOMSomehow();
    return;
  }

  // With a View Transition:
  document.startViewTransition(() => updateTheDOMSomehow());
}

When invoked, the browser automatically captures snapshots of all elements that have a view-transition-name CSS property declared on them.

It then executes the passed in callback that updates the DOM, after which it takes snapshots of the new state.

These snapshots are then arranged in a tree of pseudo-elements and animated using the power of CSS animations. Pairs of snapshots from the old and new state smoothly transition from their old position and size to their new location, while their content crossfades. If you want, you can use CSS to customize the animations.


The default transition: Cross-fade

The default view transition is a cross-fade, so it serves as a nice introduction to the API:

function spaNavigate(data) {
  // Fallback for browsers that don't support this API:
  if (!document.startViewTransition) {
    updateTheDOMSomehow(data);
    return;
  }

  // With a transition:
  document.startViewTransition(() => updateTheDOMSomehow(data));
}

Where updateTheDOMSomehow changes the DOM to the new state. That can be done however you want. For example, you can add or remove elements, change class names, or change styles.

And just like that, pages cross-fade:

The default cross-fade. Minimal demo. Source.

Okay, a cross-fade isn't that impressive. Thankfully, transitions can be customized, but first, you need to understand how this basic cross-fade worked.


How these transitions work

Let's update the previous code sample.

document.startViewTransition(() => updateTheDOMSomehow(data));

When .startViewTransition() is called, the API captures the current state of the page. This includes taking a snapshot.

Once complete, the callback passed to .startViewTransition() is called. That's where the DOM is changed. Then, the API captures the new state of the page.

Once the new state is captured, the API constructs a pseudo-element tree like this:

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

The ::view-transition sits in an overlay, over everything else on the page. This is useful if you want to set a background color for the transition.

::view-transition-old(root) is a screenshot of the old view, and ::view-transition-new(root) is a live representation of the new view. Both render as CSS 'replaced content' (like an <img>).

The old view animates from opacity: 1 to opacity: 0, while the new view animates from opacity: 0 to opacity: 1, creating a cross-fade.

All of the animation is performed using CSS animations, so they can be customized with CSS.

Customize the transition

All of the view transition pseudo-elements can be targeted with CSS, and since the animations are defined using CSS, you can modify them using existing CSS animation properties. For example:

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 5s;
}

With that one change, the fade is now really slow:

Long cross-fade. Minimal demo. Source.

Okay, that's still not impressive. Instead, the following code implements Material Design's shared axis transition:

@keyframes fade-in {
  from { opacity: 0; }
}

@keyframes fade-out {
  to { opacity: 0; }
}

@keyframes slide-from-right {
  from { transform: translateX(30px); }
}

@keyframes slide-to-left {
  to { transform: translateX(-30px); }
}

::view-transition-old(root) {
  animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
}

::view-transition-new(root) {
  animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
}

And here's the result:

Shared axis transition. Minimal demo. Source.

Transition multiple elements

In the previous demo, the whole page is involved in the shared axis transition. That works for most of the page, but it doesn't seem quite right for the heading, as it slides out just to slide back in again.

To avoid this, you can extract the header from the rest of the page so it can be animated separately. This is done by assigning a view-transition-name to the element.

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

The value of view-transition-name can be whatever you want (except for none, which means there's no transition name). It's used to uniquely identify the element across the transition.

And the result of that:

Shared axis transition with fixed header. Minimal demo. Source.

Now the header stays in place and cross-fades.

That CSS declaration caused the pseudo-element tree to change:

::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)

There are now two transition groups. One for the header, and another for the rest. These can be targeted independently with CSS, and given different transitions. Although, in this case main-header was left with the default transition, which is a cross-fade.

Well, okay, the default transition isn't just a cross fade, the ::view-transition-group also transitions:

  • Position and transform (using a transform)
  • Width
  • Height

That hasn't mattered until now, as the header is the same size and position both sides of the DOM change. But you can also extract the text in the header:

.main-header-text {
  view-transition-name: main-header-text;
  width: fit-content;
}

fit-content is used so the element is the size of the text, rather than stretching to the remaining width. Without this, the back arrow reduces the size of the header text element, rather than the same size in both pages.

So now we have three parts to play with:

::view-transition
├─ ::view-transition-group(root)
│  └─ …
├─ ::view-transition-group(main-header)
│  └─ …
└─ ::view-transition-group(main-header-text)
   └─ …

But again, just going with the defaults:

Sliding header text. Minimal demo. Source.

Now the heading text does a little satisfying slide across to make space for the back button.


Animate multiple pseudo-elements in the same way with view-transition-class

Browser Support

  • Chrome: 125.
  • Edge: 125.
  • Firefox: not supported.
  • Safari Technology Preview: supported.

Say you have a view transition with a bunch of cards but also a title on the page. To animate all cards except the title, you have to write a selector that targets each and every individual card.

h1 {
    view-transition-name: title;
}
::view-transition-group(title) {
    animation-timing-function: ease-in-out;
}

#card1 { view-transition-name: card1; }
#card2 { view-transition-name: card2; }
#card3 { view-transition-name: card3; }
#card4 { view-transition-name: card4; }

#card20 { view-transition-name: card20; }

::view-transition-group(card1),
::view-transition-group(card2),
::view-transition-group(card3),
::view-transition-group(card4),

::view-transition-group(card20) {
    animation-timing-function: var(--bounce);
}

Got 20 elements? That's 20 selectors you need to write. Adding a new element? Then you also need to grow the selector that applies the animation styles. Not exactly scalable.

The view-transition-class can be used in the view transition pseudo-elements to apply the same style rule.

#card1 { view-transition-name: card1; }
#card2 { view-transition-name: card2; }
#card3 { view-transition-name: card3; }
#card4 { view-transition-name: card4; }
#card5 { view-transition-name: card5; }

#card20 { view-transition-name: card20; }

#cards-wrapper > div {
  view-transition-class: card;
}
html::view-transition-group(.card) {
  animation-timing-function: var(--bounce);
}

The following cards example leverages the previous CSS snippet. All cards–including newly added ones–get the same timing applied with one selector: html::view-transition-group(.card).

Recording of the Cards demo. Using view-transition-class it applies the same animation-timing-function to all cards except the added or removed ones.

Debug transitions

Since view transitions are built on top of CSS animations, the Animations panel in Chrome DevTools is great for debugging transitions.

Using the Animations panel, you can pause the next animation, then scrub back and forth through the animation. During this, the transition pseudo-elements can be found in the Elements panel.

Debugging view transitions with Chrome DevTools.

Transitioning elements don't need to be the same DOM element

So far we've used view-transition-name to create separate transition elements for the header, and the text in the header. These are conceptually the same element before and after the DOM change, but you can create transitions where that isn't the case.

For example, the main video embed can be given a view-transition-name:

.full-embed {
  view-transition-name: full-embed;
}

Then, when the thumbnail is clicked, it can be given the same view-transition-name, just for the duration of the transition:

thumbnail.onclick = async () => {
  thumbnail.style.viewTransitionName = 'full-embed';

  document.startViewTransition(() => {
    thumbnail.style.viewTransitionName = '';
    updateTheDOMSomehow();
  });
};

And the result:

One element transitioning to another. Minimal demo. Source.

The thumbnail now transitions into the main image. Even though they're conceptually (and literally) different elements, the transition API treats them as the same thing because they shared the same view-transition-name.

The real code for this transition is a little more complicated than the preceding example, as it also handles the transition back to the thumbnail page. See the source for the full implementation.


Custom entry and exit transitions

Look at this example:

Entering and exiting sidebar. Minimal demo. Source.

The sidebar is part of the transition:

.sidebar {
  view-transition-name: sidebar;
}

But, unlike the header in the previous example, the sidebar doesn't appear on all pages. If both states have the sidebar, the transition pseudo-elements look like this:

::view-transition
├─ …other transition groups…
└─ ::view-transition-group(sidebar)
   └─ ::view-transition-image-pair(sidebar)
      ├─ ::view-transition-old(sidebar)
      └─ ::view-transition-new(sidebar)

However, if the sidebar is only on the new page, the ::view-transition-old(sidebar) pseudo-element won't be there. Since there's no 'old' image for the sidebar, the image-pair will only have a ::view-transition-new(sidebar). Similarly, if the sidebar is only on the old page, the image-pair will only have a ::view-transition-old(sidebar).

In the previous demo, the sidebar transitions differently depending on whether it's entering, exiting, or present in both states. It enters by sliding from the right and fading in, it exits by sliding to the right and fading out, and it stays in place when it's present in both states.

To create specific entry and exit transitions, you can use the :only-child pseudo-class to target the old or new pseudo-elements when it's the only child in the image-pair:

/* Entry transition */
::view-transition-new(sidebar):only-child {
  animation: 300ms cubic-bezier(0, 0, 0.2, 1) both fade-in,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
}

/* Exit transition */
::view-transition-old(sidebar):only-child {
  animation: 150ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-right;
}

In this case, there's no specific transition for when the sidebar is present in both states, since the default is perfect.

Async DOM updates, and waiting for content

The callback passed to .startViewTransition() can return a promise, which allows for async DOM updates, and waiting for important content to be ready.

document.startViewTransition(async () => {
  await something;
  await updateTheDOMSomehow();
  await somethingElse;
});

The transition won't be started until the promise fulfills. During this time, the page is frozen, so delays here should be kept to a minimum. Specifically, network fetches should be done before calling .startViewTransition(), while the page is still fully interactive, rather than doing them as part of the .startViewTransition() callback.

If you decide to wait for images or fonts to be ready, be sure to use an aggressive timeout:

const wait = ms => new Promise(r => setTimeout(r, ms));

document.startViewTransition(async () => {
  updateTheDOMSomehow();

  // Pause for up to 100ms for fonts to be ready:
  await Promise.race([document.fonts.ready, wait(100)]);
});

However, in some cases it's better to avoid the delay altogether, and use the content you already have.


Make the most of content you already have

In the case where the thumbnail transitions to a larger image:

The thumbnail transitioning to a larger image. Try the demo site.

The default transition is to cross-fade, which means the thumbnail could be cross-fading with a not-yet-loaded full image.

One way to handle this is to wait for the full image to load before starting the transition. Ideally this would be done before calling .startViewTransition(), so the page remains interactive, and a spinner can be shown to indicate to the user that things are loading. But in this case there's a better way:

::view-transition-old(full-embed),
::view-transition-new(full-embed) {
  /* Prevent the default animation,
  so both views remain opacity:1 throughout the transition */
  animation: none;
  /* Use normal blending,
  so the new view sits on top and obscures the old view */
  mix-blend-mode: normal;
}

Now the thumbnail doesn't fade away, it just sits underneath the full image. This means if the new view hasn't loaded, the thumbnail is visible throughout the transition. This means the transition can start straight away, and the full image can load in its own time.

This wouldn't work if the new view featured transparency, but in this case we know it doesn't, so we can make this optimization.

Handle changes in aspect ratio

Conveniently, all the transitions so far have been to elements with the same aspect ratio, but that won't always be the case. What if the thumbnail is 1:1, and the main image is 16:9?

One element transitioning to another, with an aspect ratio change. Minimal demo. Source.

In the default transition, the group animates from the before size to the after size. The old and new views are 100% width of the group, and auto height, meaning they keep their aspect ratio regardless of the group's size.

This is a good default, but it isn't what is wanted in this case. So:

::view-transition-old(full-embed),
::view-transition-new(full-embed) {
  /* Prevent the default animation,
  so both views remain opacity:1 throughout the transition */
  animation: none;
  /* Use normal blending,
  so the new view sits on top and obscures the old view */
  mix-blend-mode: normal;
  /* Make the height the same as the group,
  meaning the view size might not match its aspect-ratio. */
  height: 100%;
  /* Clip any overflow of the view */
  overflow: clip;
}

/* The old view is the thumbnail */
::view-transition-old(full-embed) {
  /* Maintain the aspect ratio of the view,
  by shrinking it to fit within the bounds of the element */
  object-fit: contain;
}

/* The new view is the full image */
::view-transition-new(full-embed) {
  /* Maintain the aspect ratio of the view,
  by growing it to cover the bounds of the element */
  object-fit: cover;
}

This means the thumbnail stays in the center of the element as the width expands, but the full image 'un-crops' as it transitions from 1:1 to 16:9.

For more detailed information, check out View transitions: Handling aspect ratio changes


Use media queries to change transitions for different device states

You may want to use different transitions on mobile versus desktop, such as this example which performs a full slide from the side on mobile, but a more subtle slide on desktop:

One element transitioning to another. Minimal demo. Source.

This can be achieved using regular media queries:

/* Transitions for mobile */
::view-transition-old(root) {
  animation: 300ms ease-out both full-slide-to-left;
}

::view-transition-new(root) {
  animation: 300ms ease-out both full-slide-from-right;
}

@media (min-width: 500px) {
  /* Overrides for larger displays.
  This is the shared axis transition from earlier in the article. */
  ::view-transition-old(root) {
    animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
      300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
  }

  ::view-transition-new(root) {
    animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in,
      300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
  }
}

You may also want to change which elements you assign a view-transition-name depending on matching media queries.


React to the 'reduced motion' preference

Users can indicate they prefer reduced motion through their operating system, and that preference is exposed in CSS.

You could chose to prevent any transitions for these users:

@media (prefers-reduced-motion) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

However, a preference for 'reduced motion' doesn't mean the user wants no motion. Instead of the preceding snippet, you could choose a more subtle animation, but one that still expresses the relationship between elements, and the flow of data.


Handle multiple view transition styles with view transition types

Browser Support

  • Chrome: 125.
  • Edge: 125.
  • Firefox: not supported.
  • Safari: 18.

Sometimes a transition from one particular view to another should have a specifically tailored transition. For example, when going to the next or to the previous page in a pagination sequence, you might want to slide the contents in a different direction depending on whether you are going to a higher page or a lower page from the sequence.

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

For this you can use view transition types, which allow you to assign one or more types to an active view transition. For example, when transitioning to a higher page in a pagination sequence use the forwards type and when going to a lower page use the backwards type. These types are only active when capturing or performing a transition, and each type can be customized through CSS to use different animations.

To use types in a same-document view transition, you pass types into the startViewTransition method. To allow this, document.startViewTransition also accepts an object: update is the callback function that updates the DOM, and types is an array with the types.

const direction = determineBackwardsOrForwards();

const t = document.startViewTransition({
  update: updateTheDOMSomehow,
  types: ['slide', direction],
});

To respond to these types, use the :active-view-transition-type() selector. Pass the type you want to target into the selector. This lets you keep the styles of multiple view transitions separated from each other, without the declarations of the one interfering with the declarations of the other.

Because types only apply when capturing or performing the transition, you can use the selector to set–or unset–a view-transition-name on an element only for the view transition with that type.

/* 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 (using the default root snapshot) */
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;
  }
}

In the following pagination demo, the page contents slide forwards or backwards based on the page number that you are navigating to. The types are determined on click upon which they get passed into document.startViewTransition.

To target any active view transition, regardless of the type, you can use the :active-view-transition pseudo-class selector instead.

html:active-view-transition {
    
}

Handle multiple view transition styles with a class name on the view transition root

Sometimes a transition from one particular type of view to another should have a specifically tailored transition. Or, a 'back' navigation should be different to a 'forward' navigation.

Different transitions when going 'back'. Minimal demo. Source.

Before transition types the way to handle these cases was to temporarily set a class name on the transition root. When calling document.startViewTransition, this transition root is the <html> element, accessible using document.documentElement in JavaScript:

if (isBackNavigation) {
  document.documentElement.classList.add('back-transition');
}

const transition = document.startViewTransition(() =>
  updateTheDOMSomehow(data)
);

try {
  await transition.finished;
} finally {
  document.documentElement.classList.remove('back-transition');
}

To remove the classes after the transition finishes, this example uses transition.finished, a promise that resolves once the transition has reached its end state. Other properties of this object are covered in the API reference.

Now you can use that class name in your CSS to change the transition:

/* 'Forward' transitions */
::view-transition-old(root) {
  animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
    300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
}

::view-transition-new(root) {
  animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in, 300ms
      cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
}

/* Overrides for 'back' transitions */
.back-transition::view-transition-old(root) {
  animation-name: fade-out, slide-to-right;
}

.back-transition::view-transition-new(root) {
  animation-name: fade-in, slide-from-left;
}

As with media queries, the presence of these classes could also be used to change which elements get a view-transition-name.


Run transitions without freezing other animations

Take a look at this demo of a video transitioning position:

Video transition. Minimal demo. Source.

Did you see anything wrong with it? Don't worry if you didn't. Here it is slowed right down:

Video transition, slower. Minimal demo. Source.

During the transition, the video appears to freeze, then the playing version of the video fades in. This is because the ::view-transition-old(video) is a screenshot of the old view, whereas the ::view-transition-new(video) is a live image of the new view.

You can fix this, but first, ask yourself if it's worth fixing. If you didn't see the 'problem' when the transition was playing at its normal speed, I wouldn't bother changing it.

If you really want to fix it, then don't show the ::view-transition-old(video); switch straight to the ::view-transition-new(video). You can do this by overriding the default styles and animations:

::view-transition-old(video) {
  /* Don't show the frozen old view */
  display: none;
}

::view-transition-new(video) {
  /* Don't fade the new view in */
  animation: none;
}

And that's it!

Video transition, slower. Minimal demo. Source.

Now the video plays throughout the transition.


Integration with the Navigation API (and other frameworks)

View transitions are specified in such a way that they can be integrated with other frameworks or libraries. For example, if your single-page application (SPA) is using a router, you can adjust the router's update mechanism to update the content using a view transition.

In the following code snippet taken from this pagination demo the Navigation API's interception handler is adjusted to call document.startViewTransition when view transitions are supported.

navigation.addEventListener("navigate", (e) => {
    // Don't intercept if not needed
    if (shouldNotIntercept(e)) return;

    // Intercept the navigation
    e.intercept({
        handler: async () => {
            // Fetch the new content
            const newContent = await fetchNewContent(e.destination.url, {
                signal: e.signal,
            });

            // The UA does not support View Transitions, or the UA
            // already provided a Visual Transition by itself (e.g. swipe back).
            // In either case, update the DOM directly
            if (!document.startViewTransition || e.hasUAVisualTransition) {
                setContent(newContent);
                return;
            }

            // Update the content using a View Transition
            const t = document.startViewTransition(() => {
                setContent(newContent);
            });
        }
    });
});

Some, but not all, browsers provide their own transition when the user performs a swipe gesture to navigate. In that case you shouldn't trigger your own view transition as it would lead to a poor or confusing user experience. The user would see two transitions—one provided by the browser and the other one by you—running in succession.

Therefore, it is recommended to prevent a view transition from starting when the browser has provided its own visual transition. To achieve this, check the value of the hasUAVisualTransition property of the NavigateEvent instance. The property is set to true when the browser has provided a visual transition. This hasUIVisualTransition property also exists on PopStateEvent instances.

In the previous snippet the check that determines whether to run the view transition takes this property into account. When there is no support for same-document view transitions or when the browser already provided its own transition, the view transition is skipped.

if (!document.startViewTransition || e.hasUAVisualTransition) {
  setContent(newContent);
  return;
}

In the following recording, the user swipes to navigate back to the previous page. The capture on the left doesn't include a check for the hasUAVisualTransition flag. The recording on the right does include the check, thereby skipping the manual view transition because the browser provided a visual transition.

Comparison of the same site without (left) and width (right) a check for hasUAVisualTransition

Animating with JavaScript

So far, all the transitions have been defined using CSS, but sometimes CSS isn't enough:

Circle transition. Minimal demo. Source.

A couple of parts of this transition can't be achieved with CSS alone:

  • The animation starts from the click location.
  • The animation ends with the circle having a radius to the farthest corner. Although, hopefully this will be possible with CSS in future.

Thankfully, you can create transitions using the Web Animation API!

let lastClick;
addEventListener('click', event => (lastClick = event));

function spaNavigate(data) {
  // Fallback for browsers that don't support this API:
  if (!document.startViewTransition) {
    updateTheDOMSomehow(data);
    return;
  }

  // Get the click position, or fallback to the middle of the screen
  const x = lastClick?.clientX ?? innerWidth / 2;
  const y = lastClick?.clientY ?? innerHeight / 2;
  // Get the distance to the furthest corner
  const endRadius = Math.hypot(
    Math.max(x, innerWidth - x),
    Math.max(y, innerHeight - y)
  );

  // With a transition:
  const transition = document.startViewTransition(() => {
    updateTheDOMSomehow(data);
  });

  // Wait for the pseudo-elements to be created:
  transition.ready.then(() => {
    // Animate the root's new view
    document.documentElement.animate(
      {
        clipPath: [
          `circle(0 at ${x}px ${y}px)`,
          `circle(${endRadius}px at ${x}px ${y}px)`,
        ],
      },
      {
        duration: 500,
        easing: 'ease-in',
        // Specify which pseudo-element to animate
        pseudoElement: '::view-transition-new(root)',
      }
    );
  });
}

This example uses transition.ready, a promise that resolves once the transition pseudo-elements have been successfully created. Other properties of this object are covered in the API reference.


Transitions as an enhancement

The View Transition API is designed to 'wrap' a DOM change and create a transition for it. However, the transition should be treated as an enhancement, as in, your app shouldn't enter an 'error' state if the DOM change succeeds, but the transition fails. Ideally the transition shouldn't fail, but if it does, it shouldn't break the rest of the user experience.

In order to treat transitions as an enhancement, take care not to use transition promises in a way that would cause your app to throw if the transition fails.

Don't
async function switchView(data) {
  // Fallback for browsers that don't support this API:
  if (!document.startViewTransition) {
    await updateTheDOM(data);
    return;
  }

  const transition = document.startViewTransition(async () => {
    await updateTheDOM(data);
  });

  await transition.ready;

  document.documentElement.animate(
    {
      clipPath: [`inset(50%)`, `inset(0)`],
    },
    {
      duration: 500,
      easing: 'ease-in',
      pseudoElement: '::view-transition-new(root)',
    }
  );
}

The problem with this example is that switchView() will reject if the transition cannot reach a ready state, but that doesn't mean that the view failed to switch. The DOM may have successfully updated, but there were duplicate view-transition-names, so the transition was skipped.

Instead:

Do
async function switchView(data) {
  // Fallback for browsers that don't support this API:
  if (!document.startViewTransition) {
    await updateTheDOM(data);
    return;
  }

  const transition = document.startViewTransition(async () => {
    await updateTheDOM(data);
  });

  animateFromMiddle(transition);

  await transition.updateCallbackDone;
}

async function animateFromMiddle(transition) {
  try {
    await transition.ready;

    document.documentElement.animate(
      {
        clipPath: [`inset(50%)`, `inset(0)`],
      },
      {
        duration: 500,
        easing: 'ease-in',
        pseudoElement: '::view-transition-new(root)',
      }
    );
  } catch (err) {
    // You might want to log this error, but it shouldn't break the app
  }
}

This example uses transition.updateCallbackDone to wait for the DOM update, and to reject if it fails. switchView no longer rejects if the transition fails, it resolves when the DOM update completes, and rejects if it fails.

If you want switchView to resolve when the new view has 'settled', as in, any animated transition has completed or skipped to the end, replace transition.updateCallbackDone with transition.finished.


Not a polyfill, but…

This is not an easy feature to polyfill. However, this helper function makes things much easier in browsers that don't support view transitions:

function transitionHelper({
  skipTransition = false,
  types = [],
  update,
}) {

  const unsupported = (error) => {
    const updateCallbackDone = Promise.resolve(update()).then(() => {});

    return {
      ready: Promise.reject(Error(error)),
      updateCallbackDone,
      finished: updateCallbackDone,
      skipTransition: () => {},
      types,
    };
  }

  if (skipTransition || !document.startViewTransition) {
    return unsupported('View Transitions are not supported in this browser');
  }

  try {
    const transition = document.startViewTransition({
      update,
      types,
    });

    return transition;
  } catch (e) {
    return unsupported('View Transitions with types are not supported in this browser');
  }
}

And it can be used like this:

function spaNavigate(data) {
  const types = isBackNavigation ? ['back-transition'] : [];

  const transition = transitionHelper({
    update() {
      updateTheDOMSomehow(data);
    },
    types,
  });

  // …
}

In browsers that don't support view transitions, updateDOM will still be called, but there won't be an animated transition.

You can also provide some classNames to add to <html> during the transition, making it easier to change the transition depending on the type of navigation.

You can also pass true to skipTransition if you don't want an animation, even in browsers that support view transitions. This is useful if your site has a user preference to disable transitions.


Working with frameworks

If you're working with a library or framework that abstracts away DOM changes, the tricky part is knowing when the DOM change is complete. Here's a set of examples, using the helper above, in various frameworks.

  • React—the key here is flushSync, which applies a set of state changes synchronously. Yes, there's a big warning about using that API, but Dan Abramov assures me it's appropriate in this case. As usual with React and async code, when using the various promises returned by startViewTransition, take care that your code is running with the correct state.
  • Vue.js—the key here is nextTick, which fulfills once the DOM has been updated.
  • Svelte—very similar to Vue, but the method to await the next change is tick.
  • Lit—the key here is the this.updateComplete promise within components, which fulfills once the DOM has been updated.
  • Angular—the key here is applicationRef.tick, which flushes pending DOM changes. As of Angular version 17 you can use withViewTransitions that comes with @angular/router.

API reference

const viewTransition = document.startViewTransition(update)

Start a new ViewTransition.

update is a function that is called once the current state of the document is captured.

Then, when the promise returned by updateCallback fulfills, the transition begins in the next frame. If the promise returned by updateCallback rejects, the transition is abandoned.

const viewTransition = document.startViewTransition({ update, types })

Start a new ViewTransition with the specified types

update is called once the current state of the document is captured.

types sets the active types for the transition when capturing or performing the transition. It is initially empty. See viewTransition.types further down for more information.

Instance members of ViewTransition:

viewTransition.updateCallbackDone

A promise that fulfills when the promise returned by updateCallback fulfills, or rejects when it rejects.

The View Transition API wraps a DOM change and creates a transition. However, sometimes you don't care about the success or failure of the transition animation, you just want to know if and when the DOM change happens. updateCallbackDone is for that use-case.

viewTransition.ready

A promise that fulfills once the pseudo-elements for the transition are created, and the animation is about to start.

It rejects if the transition cannot begin. This can be due to misconfiguration, such as duplicate view-transition-names, or if updateCallback returns a rejected promise.

This is useful for animating the transition pseudo-elements with JavaScript.

viewTransition.finished

A promise that fulfills once the end state is fully visible and interactive to the user.

It only rejects if updateCallback returns a rejected promise, as this indicates the end state wasn't created.

Otherwise, if a transition fails to begin, or is skipped during the transition, the end state is still reached, so finished fulfills.

viewTransition.types

A Set-like object that holds the active view transition's types. To manipulate the entries, use its instance methods clear(), add(), and delete().

To respond to a specific type in CSS, use the :active-view-transition-type(type) pseudo-class selector on the transition root.

Types automatically get cleaned up when the view transition finishes.

viewTransition.skipTransition()

Skip the animation part of the transition.

This won't skip calling updateCallback, as the DOM change is separate to the transition.


Default style and transition reference

::view-transition
The root pseudo-element which fills the viewport and contains each ::view-transition-group.
::view-transition-group

Absolutely positioned.

Transitions width and height between the 'before' and 'after' states.

Transitions transform between the 'before' and 'after' viewport-space quad.

::view-transition-image-pair

Absolutely positioned to fill the group.

Has isolation: isolate to limit the effect of the mix-blend-mode on the old and new views.

::view-transition-new and ::view-transition-old

Absolutely positioned to the top-left of the wrapper.

Fills 100% of the group width, but has an auto height, so it will maintain its aspect ratio rather than filling the group.

Has mix-blend-mode: plus-lighter to allow for a true cross-fade.

The old view transitions from opacity: 1 to opacity: 0. The new view transitions from opacity: 0 to opacity: 1.


Feedback

Developer feedback is always appreciated. To do so, 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.