Smooth and simple transitions with the View Transitions API

Published on Updated on

This feature was previously called "Shared Element Transitions", and is sometimes referred to as "page transitions".

The View Transition API makes it easy to change the DOM in a single step, while creating an animated transition between the two states.

It's currently behind the chrome://flags/#view-transition flag in Chrome 109+.

Transitions created with the View Transition API. Try the demo site – Requires Chrome 109+ and the chrome://flags/#view-transition flag.

Why do we need this feature?

Page transitions not only look great, they also communicate direction of flow, and make it clear which elements are related from page to page. They can even happen during data fetching, leading to a faster perception of performance.

But, we already have animation tools on the web, such as CSS transitions, CSS animations, and the Web Animation API, so why do we need a new thing to move stuff around?

The truth is, state transitions are hard, even with the tools we already have.

Even something like a simple cross-fade involves both states being present at the same time. That presents usability challenges, such as handling additional interaction on the outgoing element. Also, for users of assistive devices, there's a period where both the before and after state are in the DOM at the same time, and things may move around the tree in a way that's fine visually, but can easily cause reading position and focus to be lost.

Handling state changes is particularly challenging if the two states differ in scroll position. And, if an element is moving from one container to another, you can run into difficulties with overflow: hidden and other forms of clipping, meaning you have to restructure your CSS to get the effect you want.

It isn't impossible, it's just really hard.

View Transitions give you an easier way, by allowing you to make your DOM change without any overlap between states, but create a transition animation between the states using snapshotted views.

Additionally, although the current implementation targets single page apps (SPAs), this feature will be expanded to allow for transitions between full page loads, which is currently impossible.

Standardization status

The feature is being developed within the W3C CSS Working Group as a draft specification.

Once we're happy with the API design, we'll start the processes and checks required to ship this feature to stable.

Developer feedback is really important, so please file issues on GitHub with suggestions and questions.

The simplest transition: A 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: Add/removing elements, changing class names, changing styles… it doesn't matter.

And just like that, pages cross-fade:

The default cross-fade. Minimal demo. Source.

Ok, a cross-fade isn't that impressive. Thankfully, transitions can be customized, but before we get to that, we need to understand how this basic cross-fade worked.

Demo links in this article are less visually impressive, but also have a tiny codebase. The goal is to make it easier to read and understand all of the code, without knowing any particular SPA framework.

How these transitions work

Taking the code sample from above:

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

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

Once that's 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 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.

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

Simple customization

All of the pseudo-elements above 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.

Ok, that's still not impressive. Instead, let's implement 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.

In this example, the animation always moves from right to left, which doesn't feel natural when clicking the back button. How to change the animation depending on the direction of navigation is covered later in the article.

Transitioning 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, and giving the element layout or paint containment. layout containment has fewer restrictions, so it's usually the better choice.

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

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.

view-transition-name must be unique. If two rendered elements have the same view-transition-name at the same time, the transition will be skipped.

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, ok, the default transition isn't just a cross fade, it also transitions:

  • Position and transform (via 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 we can also extract the text in the header:

.main-header-text {
view-transition-name: main-header-text;
contain: layout;
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, whereas we want it to be 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.

View transitions use a flat structure. In the real DOM, the heading text was in the header. But, during the transition, their respective ::view-transition-groups are siblings. This is really handy when animating items from one container to another, as you don't need to worry about clipping from parent elements.

Debugging transitions

Since View Transitions are built on top of CSS animations, the animations panel in Chrome Dev Tools 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 Dev Tools.

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 instance, the main video embed can be given a view-transition-name:

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

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 is a little more complicated than the simple example above, as it also handles the transition back to the thumbnail page. See the source for the full implementation.

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.

Making the most of content you already have

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

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.

Handling 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 we want 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.

Animating width and height, as happens here on the ::view-transition-group, is generally frowned upon in web performance circles as it runs layout per frame. However, for View Transitions, we plan to optimize it so it can run off the main thread in most cases. This optimization hasn't been implemented yet.

Changing the transition depending on device state

You may want to use different transitions on mobile vs 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.

Reacting to the 'reduced motion' preference

Users can indicate they prefer reduced motion via their operating system, and that preference is exposed via 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 above, you could choose a more subtle animation, but one that still expresses the relationship between elements, and the flow of data.

Changing the transition depending on the type of navigation

Sometimes a navigation from one particular type of page 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.

The best way to handle these cases is to set a class name on <html>, also known as the document element:

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');
}

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.

The above doesn't define how isBackNavigation is determined, as that depends on how the navigation is performed. The Navigation API is really useful here, and that's what's used in the minimal demo.

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.

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.domUpdated;
}

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.domUpdated 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.domUpdated with transition.finished.

Not a polyfill, but…

I don't think this feature can be polyfilled in any useful way, but I'm happy to be proven wrong!

However, this helper function makes things much easier in browsers that don't support view transitions:

function transitionHelper({
skipTransition = false,
classNames = [],
updateDOM,
}
) {
if (skipTransition || !document.startViewTransition) {
const domUpdated = Promise.resolve(updateDOM()).then(() => undefined);

return {
ready: Promise.reject(Error('View transitions unsupported')),
domUpdated,
finished: domUpdated,
};
}

document.documentElement.classList.add(...classNames);

const transition = document.startViewTransition(updateDOM);

transition.finished.finally(() =>
document.documentElement.classList.remove(...classNames)
);

return transition;
}

And it can be used like this:

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

const transition = transitionHelper({
classNames,
updateDOM() {
updateTheDOMSomehow(data);
},
});

// …
}

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.

API reference

const viewTransition = document.startViewTransition(domUpdateCallback)

Start a new ViewTransition.

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

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

Instance members of ViewTransition:

viewTransition.domUpdated

A promise that fulfills when the promise returned by domUpdateCallback 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/failure of the transition animation, you just want to know if and when the DOM change happens. domUpdated 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 domUpdateCallback 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 domUpdateCallback 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.skipTransition()

Skip the animation part of the transition.

This won't skip calling domUpdateCallback, 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 plus-lighter 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 really important at this stage, so please file issues on GitHub with suggestions and questions.

Updated on Improve article

We serve cookies on this site to analyze traffic, remember your preferences, and optimize your experience.