Using element-scoped view transitions
Element-scoped view transitions are scoped to a particular element's DOM subtree. They have many advantages over document-scoped view transitions: you can run transitions on subsections of the document while keeping the rest of it interactive, run multiple transitions simultaneously — including nested transitions — and solve several other issues.
This article covers how element-scoped view transitions work and how to use them.
Note:
"Document-scoped view transitions" refer to same-document view transitions, that is, transitions initiated via the Document.startViewTransition() method.
Element-scoped view transitions are initiated via the same method, called on an individual element (see Element.startViewTransition()). Element-scoped view transitions are not available for cross-document transitions.
Problems with document-scoped view transitions
Document-scoped view transitions are useful for animating DOM content updates across a whole document. You can apply different animations to different parts of the page, a single transition animation to the whole page, or no animations at all.
You can also use different view transition types to apply different animations to the same element depending on the circumstance - for example, whether it is the next or previous element in a sequence.
However, document-scoped view transitions have several shortcomings:
- You can't run more than one view transition at a time.
- When a view transition is running, the page ceases to be interactive until the transition is finished.
- The pseudo-element tree associated with a document-scoped view transition sits over the top of everything else on the page. If another element is positioned above the updating part of the page when the transition animation starts (for example, using
z-index), the positioned element will disappear underneath the transition for the animation's duration, which is probably not the effect you want. - Related to the previous issue, if the updating part of the page is clipped by an ancestor wrapper using
overflow, it will spill out of the container when the animation starts.
Element-scoped view transitions can solve these problems. Let's look at some examples to see how.
Basic element-scoped example
This example features a list of links. When a link is clicked, its content changes, and that change is animated via an element-scoped view transition. The example also contains an element that slightly overlaps the transitioning element; we're using this to show how z-index problems can be avoided.
HTML
The markup includes a <ul> list of links between two <p> elements containing text content.
<p>
I'm baby xOXO bespoke cupidatat PBR&B, affogato cronut 3 wolf moon ea narwhal
asymmetrical.
</p>
<ul>
<li><a href="#">Standard</a></li>
<li><a href="#">Standard</a></li>
<li><a href="#">Standard</a></li>
<li><a href="#">Standard</a></li>
</ul>
<p>
Kombucha laborum tempor iceland pour-over. Keytar in echo park gorpcore
bespoke.
</p>
CSS
We start by giving the <ul> some background and border styling. We also give it a position of relative, so we can absolutely position descendants relative to the <ul>.
ul {
border: 2px solid #999;
background: #ccc;
position: relative;
}
Next, we give the <a> elements their own border styles and apply a transition so that border style updates on state changes are smoothly animated. On :hover and :focus, we change the link border-color to black.
a {
border: 2px solid #aaa;
transition: border 0.6s;
}
a:hover,
a:focus {
border-color: black;
}
The most relevant CSS for view transitions defines custom animation settings for the old and new transition states, which rotate the old DOM state out and the new DOM state in. Note that we've applied an animation-delay value to the rotate-in animation (the second 0.3s value) to ensure that it starts only when the rotate-out animation ends.
::view-transition-old(*) {
animation: rotate-out 0.3s 1 both linear;
}
::view-transition-new(*) {
animation: rotate-in 0.3s 0.3s 1 both linear;
}
@keyframes rotate-out {
from {
rotate: 0deg x;
}
to {
rotate: 90deg x;
}
}
@keyframes rotate-in {
from {
rotate: -90deg x;
}
to {
rotate: 0deg x;
}
}
Finally, we create some generated content on the <ul> element using the ::before pseudo-element and positioning it over the <ul> element. The generated content contains a transparent gradient effect.
ul::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: -5px;
width: 100px;
background-image: linear-gradient(
to right,
rgb(255 255 255),
rgb(255 255 255) 25%,
rgb(255 255 255 / 0)
);
z-index: 1;
}
JavaScript
In the script, we grab a reference to the <ul> element and add a click event listener to it. When it is clicked, we check that the event target is an <a> element. If it is, we invoke startViewTransition() on the clicked <a> element, toggling its content between "Standard" and "Alternative" via the toggleText() function.
Note that we've also included feature detection to ensure the code works in browsers that don't support startViewTransition(): before running startViewTransition(), we check that it exists on the target element. If not, we just run the toggleText() function and return, so the DOM still updates, but without the transition animation.
const list = document.querySelector("ul");
list.addEventListener("click", handleClick);
function handleClick(e) {
function toggleText() {
if (e.target.textContent === "Standard") {
e.target.textContent = "Alternative";
} else {
e.target.textContent = "Standard";
}
}
if (e.target.tagName === "A") {
if (!e.target.startViewTransition) {
toggleText();
return;
}
e.target.startViewTransition(() => {
toggleText();
});
}
}
Result
Click/activate the links to see the view tranasition on each one.
Each <a> element has its own view transition, scoped just to that element. The rest of the page stays interactive while a view transition is ongoing, so you can run multiple view transitions at the same time. In addition, the transitioning elements stay below the overlapping generated content positioned above them.
Differences between element- and document-scoped transitions
The previous example demonstrates how element-scoped view transitions fix some of the issues of their document-scoped counterparts. This is largely thanks to the difference in pseudo-element tree placement. Instead of being added inside the :root element, the browser adds element-scoped view transition trees inside the element on which Element.startViewTransition() is called.
In the previous example, one of the pseudo-element trees would look like this:
<a href="#"> ├─ ::view-transition │ └─ ::view-transition-group(root) │ └─ ::view-transition-image-pair(root) │ ├─ ::view-transition-old(root) │ └─ ::view-transition-new(root) | | "Alternative" </a>
This means that the transition is scoped to the <a> element (referred to as the "transition root" or "scope") and its DOM content, so it doesn't interfere with other elements or ongoing view transitions. When the view transition starts, the browser looks for elements to snapshot only inside that scope. During the snapshotting process — up until the ViewTransition.updateCallbackDone promise fulfills — rendering is paused only inside the scope.
The ::view-transition pseudo-element has the same size and shape as the transition root element, and renders only on top of it, not the rest of the page. Because of this, the layering order of elements outside of the transition root is respected.
Self-participating scopes and clipping
Another key feature of element-scoped view transitions is that, when the transitioned element is clipped by its container (via overflow: scroll, for example), the element remains clipped during the transition animation.
This happens because the following are automatically set on the scope root element:
- A
view-transition-namevalue ofroot, which ensures that the root element participates in its own transition (referred to as self-participation). - A
view-transition-groupvalue ofcontain, which enables nested view transition groups for the scope. Anoverflowvalue ofclipis then set on the resulting::view-transition-group()pseudo-element, which causes the pseudo-element tree's contents to be clipped to the scope. - A
view-transition-scopevalue ofall, which ensures thatview-transition-namevalues scope to the element's subtree (see Nested element-scoped view transitions for more details).
Note:
You can opt a view transition out of self-participation by setting view-transition-name: none on the transition root element. However, this can result in undesirable behavior, such as the transition spilling out of the root in clipping cases. If you choose to do this, test carefully and make sure the scope does not clip its contents.
Let's look at another example, this time to demonstrate the clipping behavior.
HTML
The HTML is similar to the previous example, except that the central element is now a <section> containing a paragraph of text. We also include a <button> that can be pressed to change the paragraph content.
<p>
I'm baby xOXO bespoke cupidatat PBR&B, affogato cronut 3 wolf moon ea narwhal
asymmetrical.
</p>
<section>
<p>
I'm baby xOXO bespoke cupidatat PBR&B, affogato cronut 3 wolf moon ea
narwhal asymmetrical. Af health goth shaman in slow-carb godard echo park.
Tofu farm-to-table labore salvia tote bag food truck dolore gluten-free
poutine kombucha fanny pack +1 franzen lyft fugiat. Chicharrones next level
jianbing, enamel pin seitan cardigan bruh snackwave beard incididunt dolor
lumber before they sold out dreamcatcher single-origin coffee.
</p>
</section>
<button>Change!</button>
<p>
Kombucha laborum tempor iceland pour-over. Keytar in echo park gorpcore
bespoke.
</p>
CSS
To begin with, we set a fixed height and overflow-y: scroll on the <section> to cause the <p> content to scroll vertically.
section {
height: 150px;
overflow-y: scroll;
}
Next, we set a view-transition-name on the nested <p> element, with matching names in the custom ::view-transition-old() and ::view-transition-new() pseudo-elements. This means that only <p> will animate, not the rest of the transition scope.
section p {
view-transition-name: content;
}
::view-transition-old(content) {
animation: rotate-out 0.3s 1 both linear;
}
::view-transition-new(content) {
animation: rotate-in 0.3s 0.3s 1 both linear;
}
For brevity, the @keyframes definition code is hidden. It is nearly identical to the previous example; the only difference is that the rotation in this example occurs around the y-axis rather than the x-axis.
JavaScript
The script defines a content array containing two different strings to swap the <p> content between. We then grab references to the <section>, <p>, and <button> elements.
const content = ["I'm baby xOXO ...", "Kombucha laborum ..."];
const section = document.querySelector("section");
const para = document.querySelector("section p");
const btn = document.querySelector("button");
Next, we add a click event listener to the <button>. Each time the button is clicked, a view transition is triggered: inside the startViewTransition() call, the <p> element's textContent is toggled between the two content array elements via the toggleText() function. We've also included simple feature detection that falls back to running toggleText() directly in browsers that don't support Element.startViewTransition().
btn.addEventListener("click", handleClick);
function toggleText() {
if (para.className === "1") {
para.className = "0";
} else {
para.className = "1";
}
para.textContent = content[Number(para.className)];
}
function handleClick() {
if (!section.startViewTransition) {
toggleText();
return;
}
const vt = section.startViewTransition(() => {
toggleText();
});
}
Result
Click the button, and note how the transition doesn't spill outside the <section> — it remains clipped to the transition scope.
Nested element-scoped view transitions
One more aspect of element-scoped view transitions worth noting is that you can nest view transitions and have them running concurrently without interference. This is possible because, as mentioned earlier, the browser automatically assigns a view-transition-scope value of all to the scope root elements. This ensures that view-transition-name values scope to the element's subtree, and prevents elements and their contents from being captured by an outer, concurrent view transition. Browsers ignore elements that have view-transition-scope: all set during the snapshotting process.
Let's look at a demonstration of nested element-scoped view transitions.
The HTML is the same as for the first example, except there are now two lists of links inside an extra wrapper element.
CSS
The two lists are arranged side-by-side within the .wrapper element using flexbox. We give the wrapper a view-transition-name of wrapper, and then we give each list a different background color:
.wrapper {
display: flex;
gap: 20px;
view-transition-name: wrapper;
}
.one {
background-color: orange;
}
.two {
background-color: green;
}
We also apply different animations to the general old and new transition pseudo-elements, and then separate animations to the wrapper old and new transition pseudo-elements:
::view-transition-old(*) {
animation: rotate-out 0.3s 1 both linear;
}
::view-transition-new(*) {
animation: rotate-in 0.3s 0.3s 1 both linear;
}
::view-transition-old(wrapper) {
animation: fade-out 0.3s 1 both linear;
}
::view-transition-new(wrapper) {
animation: fade-in 0.3s 0.3s 1 both linear;
}
We have hidden the rest of the CSS for brevity.
JavaScript
The JavaScript is similar to the first example, except that here two element-scoped view transitions run concurrently each time a link is clicked. The first one toggles the text of the link between "Standard" and "Alternative" (via the toggleText() function), and the second one swaps the position of the two lists inside the DOM (via the togglePosition() function). As before, we've included feature detection code, so the example still works in browsers that don't support Element.startViewTransition().
const lists = document.querySelectorAll("ul");
const wrapper = document.querySelector(".wrapper");
lists.forEach((list) => {
list.addEventListener("click", handleClick);
});
function handleClick(e) {
function toggleText() {
if (e.target.textContent === "Standard") {
e.target.textContent = "Alternative";
} else {
e.target.textContent = "Standard";
}
}
function togglePosition() {
if (lists[0].nextElementSibling === lists[1]) {
wrapper.insertBefore(lists[1], lists[0]);
} else {
wrapper.insertBefore(lists[0], lists[1]);
}
}
if (e.target.tagName === "A") {
if (!e.target.startViewTransition) {
toggleText();
togglePosition();
return;
}
e.target.startViewTransition(() => {
toggleText();
});
wrapper.startViewTransition(() => {
togglePosition();
});
}
}
Result
Click the text inside any box. Note how the text toggle and the list swap happen simultaneously - both nested transitions run at the same time without interfering with one another.
Querying active view transitions
The following properties enable you to query active element-scoped view transitions:
ViewTransition.transitionRoot: Returns a reference to the root element of the view transition's scope.Element.activeViewTransition: Returns a reference to an element's activeViewTransition, if one exists.
For example, if you want to process the animations active on an element in some way during a transition, you can access them using transitionRoot:
function processAnimations(transition) {
const anims = transition.transitionRoot.getAnimations();
// ...
}
// ...
const transition = el.startViewTransition();
transition.ready.then(() => processAnimations(transition));
See also
- View Transition API
- Run concurrent and nested view transitions with element-scoped view transitions on developer.chrome.com