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.

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

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

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

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

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

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

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.

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

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

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

js
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().

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

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

css
::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().

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

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:

js
function processAnimations(transition) {
  const anims = transition.transitionRoot.getAnimations();
  // ...
}

// ...

const transition = el.startViewTransition();
transition.ready.then(() => processAnimations(transition));

See also