Using the View Transitions API
This article explains the theory behind how the View Transitions API works, how to create view transitions and customize the transition animations, and how to manipulate active view transitions. This covers view transitions for both DOM state updates in a single-page app (SPA), and navigating between documents in a multi-page app (MPA).
The view transition process
Let's walk through the process by which a view transition works:
- A view transition is triggered. How this is done depends on the type of view transition:
- In the case of same-document transitions (SPAs), a view transition is triggered by passing the function that would trigger the view change DOM update as a callback to the
document.startViewTransition()
method. - In the case of cross-document transitions (MPAs), a view transition is triggered by initiating navigation to a new document. Both the current and destination documents of the navigation need to be on the same origin, and opt-in to the view transition by including a
@view-transition
at rule in their CSS with anavigation
descriptor ofauto
.Note: An active view transition has an associated
ViewTransition
instance (for example, returned bystartViewTransition()
in the case of same-document (SPA) transitions). TheViewTransition
object includes several promises, allowing you to run code in response to different parts of the view transition process being reached. See Controlling view transitions with JavaScript for more information.
- In the case of same-document transitions (SPAs), a view transition is triggered by passing the function that would trigger the view change DOM update as a callback to the
- On the current (old page) view, the API captures snapshots of elements that have a
view-transition-name
declared on them. - The view change occurs:
-
In the case of same-document transitions (SPAs), the callback passed to
startViewTransition()
is invoked, which causes the DOM to change. When the callback has run successfully, theViewTransition.updateCallbackDone
promise fulfills, allowing you to respond to the DOM updating. - In the case of cross-document transitions (MPAs), the navigation occurs between the current and destination documents.
-
In the case of same-document transitions (SPAs), the callback passed to
-
The API captures snapshots from the new view as a live representation.
At this point, the view transition is about to run, and the
ViewTransition.ready
promise fulfills, allowing you to respond by running a custom JavaScript animation instead of the default, for example. - The old page snapshots animate "out", while the new view snapshots animate "in". By default, the old view snapshots animate from
opacity
1 to 0, and the new view snapshots animate fromopacity
0 to 1, which creates a cross-fade. - When the transition animations have reached their end states, the
ViewTransition.finished
promise fulfills, allowing you to respond.
Note:
If the document's page visibility state is hidden
(for example if the document is obscured by a window, the browser is minimized, or another browser tab is active) during a document.startViewTransition()
call, the view transition is skipped entirely.
The view transition pseudo-element tree
To handle creating the outbound and inbound transition animations, the API constructs a pseudo-element tree with the following structure:
::view-transition └─ ::view-transition-group(root) └─ ::view-transition-image-pair(root) ├─ ::view-transition-old(root) └─ ::view-transition-new(root)
Note: a ::view-transition-group
subtree is created for every captured view-transition-name
.
In the case of same-document transitions (SPAs), the pseudo-element tree is made available in the document. In the case of cross-document transitions (MPAs), the pseudo-element tree is made available in the destination document only.
The most interesting parts of the tree structure are as follows:
::view-transition
is the root of view transitions overlay, which contains all view transition snapshot groups and sits over the top of all other page content.- A
::view-transition-group
acts as a container for each view transition snapshot group. Theroot
argument specifies the default snapshot group — the view transition animation will apply to the snapshot whoseview-transition-name
isroot
. By default, this is the:root
element, because the default browser styles define this:Be aware however that page authors can change this by unsetting the above, and settingcss:root { view-transition-name: root; }
view-transition-name: root
on a different element. ::view-transition-old
targets the static snapshot of the old page element, and::view-transition-new
targets the live snapshot of the new page element. Both of these render as replaced content, in the same manner as an<img>
or<video>
, meaning that they can be styled with handy properties likeobject-fit
andobject-position
.
Note: It is possible to target different DOM elements with different custom view transition animations by setting a different view-transition-name
on each one. In such cases, a ::view-transition-group
is created for each one. See Different animations for different elements for an example.
Note: As you'll see later, to customize the outbound and inbound animations you need to target the ::view-transition-old
and ::view-transition-new
pseudo-elements with your animations, respectively.
Creating a basic view transition
This section illustrates how to create a basic view transition, in both the SPA and MPA case.
Basic SPA view transition
As an example, an SPA may include functionality to fetch new content and update the DOM in response to an event of some kind, such as a navigation link being clicked or an update being pushed from the server. In our View Transitions SPA demo we've simplified this to a displayNewImage()
function that shows a new full-size image based on the thumbnail that was clicked. We've encapsulated this inside an updateView()
function that only calls the View Transition API if the browser supports it:
function updateView(event) {
// Handle the difference in whether the event is fired on the <a> or the <img>
const targetIdentifier = event.target.firstChild || event.target;
const displayNewImage = () => {
const mainSrc = `${targetIdentifier.src.split("_th.jpg")[0]}.jpg`;
galleryImg.src = mainSrc;
galleryCaption.textContent = targetIdentifier.alt;
};
// Fallback for browsers that don't support View Transitions:
if (!document.startViewTransition) {
displayNewImage();
return;
}
// With View Transitions:
const transition = document.startViewTransition(() => displayNewImage());
}
This code is enough to handle the transition between displayed images. Supporting browsers will show the change from old to new images and captions as a smooth cross-fade (the default view transition). It will still work in non-supporting browsers but without the nice animation.
Basic MPA view transition
When creating a cross-document (MPA) view transition, the process is even simpler than for SPAs. No JavaScript is required, as the view update is triggered by a cross-document, same-origin navigation rather than a JavaScript-initiated DOM change. To enable a basic MPA view transition, you need to specify a @view-transition
at-rule in the CSS for both the current and destination documents to opt them in, like so:
@view-transition {
navigation: auto;
}
Our View Transitions MPA demo shows this at-rule in action, and additionally demonstrates how to customize the outbound and inbound animations of the view transition.
Note: Currently MPA view transitions can only be created between same-origin documents, but this restriction may be relaxed in future implementations.
Customizing your animations
The View Transitions pseudo-elements have default CSS Animations applied (which are detailed in their reference pages).
Most appearance transitions are given a default smooth cross-fade animation, as mentioned above. There are some exceptions:
height
andwidth
transitions have a smooth scaling animation applied.position
andtransform
transitions have a smooth movement animation applied.
You can modify the default animations in any way you want using regular CSS — target the "from" animation with ::view-transition-old
, and the "to" animation with ::view-transition-new
.
For example, to change the speed of both:
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.5s;
}
It is recommended that you target the ::view-transition-group()
with such styles in cases where you want to apply them to ::view-transition-old()
and ::view-transition-new()
. Because of the pseudo-element hierarchy and default user-agent styling, the styles will be inherited by both. For example:
::view-transition-group(root) {
animation-duration: 0.5s;
}
Note: This is also a good option for safeguarding your code — ::view-transition-group()
also animates and you could end up with different durations for the group
/image-pair
pseudo-elements versus the old
and new
pseudo-elements.
In the case of cross-document (MPA) transitions, the pseudo-elements need to be included in the destination document only for the view transition to work. If you want to use the view transition in both directions, you'll need to include it in both, of course.
Our View Transitions MPA demo includes the above CSS, but takes the customization a step further, defining custom animations and applying them to the ::view-transition-old(root)
and ::view-transition-new(root)
pseudo-elements. The result is that the default cross-fade transition is swapped out for a "swipe up" transition when navigation occurs:
/* Create a custom animation */
@keyframes move-out {
from {
transform: translateY(0%);
}
to {
transform: translateY(-100%);
}
}
@keyframes move-in {
from {
transform: translateY(100%);
}
to {
transform: translateY(0%);
}
}
/* Apply the custom animation to the old and new page states */
::view-transition-old(root) {
animation: 0.4s ease-in both move-out;
}
::view-transition-new(root) {
animation: 0.4s ease-in both move-in;
}
Different animations for different elements
By default, all of the different elements that change during the view update are transitioned using the same animation. If you want some elements to animate differently from the default root
animation, you can separate them out using the view-transition-name
property. For example, in our View Transitions SPA demo the <figcaption>
elements are given a view-transition-name
of figure-caption
to separate them from the rest of the page in terms of view transitions:
figcaption {
view-transition-name: figure-caption;
}
With this CSS applied, the generated pseudo-element tree will now look like this:
::view-transition ├─ ::view-transition-group(root) │ └─ ::view-transition-image-pair(root) │ ├─ ::view-transition-old(root) │ └─ ::view-transition-new(root) └─ ::view-transition-group(figure-caption) └─ ::view-transition-image-pair(figure-caption) ├─ ::view-transition-old(figure-caption) └─ ::view-transition-new(figure-caption)
The existence of the second set of pseudo-elements allows separate view transition styling to be applied just to the <figcaption>
. The different old and new view captures are handled separately from one another.
Note: The value of view-transition-name
can be anything you want except for none
— the none
value specifically means that the element will not participate in a view transition.
view-transition-name
values must also be unique. If two rendered elements have the same view-transition-name
at the same time, ViewTransition.ready
will reject and the transition will be skipped.
The following code applies a custom animation just to the <figcaption>
:
@keyframes grow-x {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@keyframes shrink-x {
from {
transform: scaleX(1);
}
to {
transform: scaleX(0);
}
}
::view-transition-group(figure-caption) {
height: auto;
right: 0;
left: auto;
transform-origin: right center;
}
::view-transition-old(figure-caption) {
animation: 0.25s linear both shrink-x;
}
::view-transition-new(figure-caption) {
animation: 0.25s 0.25s linear both grow-x;
}
Here we've created a custom CSS animation and applied it to the ::view-transition-old(figure-caption)
and ::view-transition-new(figure-caption)
pseudo-elements. We've also added a number of other styles to both to keep them in the same place and stop the default styling from interfering with our custom animations.
Note: You can use *
as the identifier in a pseudo-element to target all snapshot pseudo-elements, regardless of what name they have. For example:
::view-transition-group(*) {
animation-duration: 2s;
}
Taking advantage of the default animation styles
Note that we also discovered another transition option that is simpler and produced a nicer result than the above. Our final <figcaption>
view transition ended up looking like this:
figcaption {
view-transition-name: figure-caption;
}
::view-transition-group(figure-caption) {
height: 100%;
}
This works because, by default, ::view-transition-group
transitions width
and height
between the old and new views with a smooth scale. We just needed to set a fixed height
on both states to make it work.
Note: Smooth and simple transitions with the View Transitions API contains several other customization examples.
Controlling view transitions with JavaScript
A view transition has an associated ViewTransition
object instance, which contains several promise members allowing you to run JavaScript in response to different states of the transition being reached. For example, ViewTransition.ready
fulfills once the pseudo-element tree is created and the animation is about to start, whereas ViewTransition.finished
fulfills once the animation is finished, and the new page view is visible and interactive to the user.
The ViewTransition
can be accessed like so:
- In the case of same-document (SPA) transitions, the
document.startViewTransition()
method returns theViewTransition
associated with the transition. - In the case of cross-document (MPA) transitions:
- A
pageswap
event is fired when a document is about to be unloaded due to a navigation. Its event object (PageSwapEvent
) provides access to theViewTransition
via thePageSwapEvent.viewTransition
property, as well as aNavigationActivation
viaPageSwapEvent.activation
containing the navigation type and current and destination document history entries.Note: If the navigation has a cross-origin URL anywhere in the redirect chain, the
activation
property returnsnull
. - A
pagereveal
event is fired when a document is first rendered, either when loading a fresh document from the network or activating a document (either from back/forward cache (bfcache) or prerender). Its event object (PageRevealEvent
) provides access to theViewTransition
via thePageRevealEvent.viewTransition
property.
- A
Let's have a look at some example code to show how these features could be used.
A JavaScript-powered custom same-document (SPA) transition
The following JavaScript could be used to create a circular reveal view transition emanating from the position of the user's cursor on click, with animation provided by the Web Animations API.
// Store the last click event
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),
);
// Create 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 animation also requires the following CSS, to turn off the default CSS animation and stop the old and new view states from blending in any way (the new state "wipes" right over the top of the old state, rather than transitioning in):
::view-transition-image-pair(root) {
isolation: auto;
}
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
display: block;
}
A JavaScript-powered custom cross-document (MPA) transition
The List of Chrome DevRel team members demo provides a basic set of team profile pages, and demonstrates how to use the pageswap
and pagereveal
events to customize the outgoing and inbound animations of a cross-document view transition based on the "from" and "to" URLs.
The pageswap
event listener looks as follows. This sets view transition names on the elements on the outbound page that link to the profile pages. When navigating from the home page to a profile page, custom animations are provided only for the linked element that is clicked in each case.
window.addEventListener("pageswap", async (e) => {
// Only run this if an active view transition exists
if (e.viewTransition) {
const currentUrl = e.activation.from?.url
? new URL(e.activation.from.url)
: null;
const targetUrl = new URL(e.activation.entry.url);
// Going from profile page to homepage
// ~> The big img and title are the ones!
if (isProfilePage(currentUrl) && isHomePage(targetUrl)) {
// Set view-transition-name values on the elements to animate
document.querySelector(`#detail main h1`).style.viewTransitionName =
"name";
document.querySelector(`#detail main img`).style.viewTransitionName =
"avatar";
// Remove view-transition-names after snapshots have been taken
// Stops naming conflicts resulting from the page state persisting in BFCache
await e.viewTransition.finished;
document.querySelector(`#detail main h1`).style.viewTransitionName =
"none";
document.querySelector(`#detail main img`).style.viewTransitionName =
"none";
}
// Going to profile page
// ~> The clicked items are the ones!
if (isProfilePage(targetUrl)) {
const profile = extractProfileNameFromUrl(targetUrl);
// Set view-transition-name values on the elements to animate
document.querySelector(`#${profile} span`).style.viewTransitionName =
"name";
document.querySelector(`#${profile} img`).style.viewTransitionName =
"avatar";
// Remove view-transition-names after snapshots have been taken
// Stops naming conflicts resulting from the page state persisting in BFCache
await e.viewTransition.finished;
document.querySelector(`#${profile} span`).style.viewTransitionName =
"none";
document.querySelector(`#${profile} img`).style.viewTransitionName =
"none";
}
}
});
Note: We remove the view-transition-name
values after snapshots have been taken in each case. If we left them set, they would persist in the page state saved in the bfcache upon navigation. If the back button was then pressed, the pagereveal
event handler of the page being navigated back to would then attempt to set the same view-transition-name
values on different elements. If multiple elements have the same view-transition-name
set, the view transition is skipped.
The pagereveal
event listener looks as follows. This works in a similar way to the pageswap
event listener, although bear in mind that here we are customizing the "to" animation, for page elements on the new page.
window.addEventListener("pagereveal", async (e) => {
// If the "from" history entry does not exist, return
if (!navigation.activation.from) return;
// Only run this if an active view transition exists
if (e.viewTransition) {
const fromUrl = new URL(navigation.activation.from.url);
const currentUrl = new URL(navigation.activation.entry.url);
// Went from profile page to homepage
// ~> Set VT names on the relevant list item
if (isProfilePage(fromUrl) && isHomePage(currentUrl)) {
const profile = extractProfileNameFromUrl(fromUrl);
// Set view-transition-name values on the elements to animate
document.querySelector(`#${profile} span`).style.viewTransitionName =
"name";
document.querySelector(`#${profile} img`).style.viewTransitionName =
"avatar";
// Remove names after snapshots have been taken
// so that we're ready for the next navigation
await e.viewTransition.ready;
document.querySelector(`#${profile} span`).style.viewTransitionName =
"none";
document.querySelector(`#${profile} img`).style.viewTransitionName =
"none";
}
// Went to profile page
// ~> Set VT names on the main title and image
if (isProfilePage(currentUrl)) {
// Set view-transition-name values on the elements to animate
document.querySelector(`#detail main h1`).style.viewTransitionName =
"name";
document.querySelector(`#detail main img`).style.viewTransitionName =
"avatar";
// Remove names after snapshots have been taken
// so that we're ready for the next navigation
await e.viewTransition.ready;
document.querySelector(`#detail main h1`).style.viewTransitionName =
"none";
document.querySelector(`#detail main img`).style.viewTransitionName =
"none";
}
}
});
Stabilizing page state to make cross-document transitions consistent
Before running a cross-document transition, you ideally want to wait until the state of the page stabilizes, relying on render blocking to ensure that:
- Critical styles are loaded and applied.
- Critical scripts are loaded and run.
- The HTML visible for the user's initial view of the page has been parsed, so it renders consistently.
Styles are render blocked by default, and scripts can be render blocked using the blocking="render"
attribute.
To ensure that your initial HTML has been parsed and will always render consistently before the transition animation runs, you can use <link rel="expect">
. In this element, you include the following attributes:
rel="expect"
to indicate that you want to use this<link>
element to render block some HTML on the page.href="#element-id"
to indicate the ID of the elment you want to render block.blocking="render"
to render block the specified HTML.
Let's explore what this looks like with a simple example HTML document:
<!doctype html>
<html lang="en">
<head>
<!-- This will be render-blocking by default -->
<link rel="stylesheet" href="style.css" />
<!-- Marking critical scripts as render blocking will
ensure they're run before the view transition is activated -->
<script async href="layout.js" blocking="render"></script>
<!-- Use rel="expect" and blocking="render" to ensure the
#lead-content element is visible and fully parsed before
activating the transition -->
<link rel="expect" href="#lead-content" blocking="render" />
</head>
<body>
<h1>Page title</h1>
<nav>...</nav>
<div id="lead-content">
<section id="first-section">The first section</section>
<section>The second section</section>
</div>
</body>
</html>
The result is that document rendering is blocked until the lead content <div>
has been parsed, ensuring a consistent view transition.
You can also specify a media
attribute on <link rel="expect">
elements. For example, you might want to block rendering on a smaller amount of content when loading the page on a narrow-screen device, than on a wide-screen device. This makes sense — on a mobile, less content will be visible when the page first loads than in the case of a desktop.
This could be achieved with the following HTML:
<link
rel="expect"
href="#lead-content"
blocking="render"
media="screen and (min-width: 641px)" />
<link
rel="expect"
href="#first-section"
blocking="render"
media="screen and (max-width: 640px)" />