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:
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:
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:
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:
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:
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
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)
.
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.
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:
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:
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 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?
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:
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
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.
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.
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:
Did you see anything wrong with it? Don't worry if you didn't. Here it is slowed right down:
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!
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.
Animating with JavaScript
So far, all the transitions have been defined using CSS, but sometimes CSS isn't enough:
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.
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-name
s, so the transition was skipped.
Instead:
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 bystartViewTransition
, 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 usewithViewTransitions
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 byupdateCallback
rejects, the transition is abandoned.const viewTransition = document.startViewTransition({ update, types })
Start a new
ViewTransition
with the specified typesupdate
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. SeeviewTransition.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-name
s, or ifupdateCallback
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 methodsclear()
,add()
, anddelete()
.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
andheight
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 themix-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
toopacity: 0
. The new view transitions fromopacity: 0
toopacity: 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.