::view-transition-old()

Sunkanmi Fafowora on

The ::view-transition-old() pseudo-element applies styles to the “old” snapshot of an element during a view transition. This means styles on this pseudo-element work as exit transitions whenever a new page has been loaded.

A fun fact about this visual snapshot is that, according to the specification, it is a replaced element (the same as other elements like <img>, <iframe>, and embedded links) and can be manipulated with the same properties that style replaced elements like <img>.

::view-transition-old(item) {
  animation-name: fadeOut;
}

@keyframes fadeOut {
  to {
    opacity: 0;
    transform: translateY(-10px);
  }
}
A live demo and the source code are available

Syntax

::view-transition-old(<pt-name-selector>);

where…

  • <pt-name-selector>: Selects a view transition name and can either be:
    • *: Targets all the view transition groups on the page
    • <custom-ident>: A valid CSS identifier that is not a keyword
    • root: Targets the :root::view-transition-group(*) in the browser which represents the default snapshot group created by the browser for the whole transition on the entire page. This group also includes all elements that have not been assigned to a specific view transition name via the view-transition-name property.

Arguments

/* targets all the view transition groups */
::view-transition-old(*) {
  animation: slideIn 0.5s ease-in;
}

/* targets the whole page */
::view-transition-old(root) {
  animation: slideRight 0.2s ease-out;
}

/* select a specific view-transition-name,
   e.g. view-transition-name: pigeon;
*/
::view-transition-old(pigeon) {
  animation: slideLeft 0.1s ease;
}

Basic usage

The ::view-transition-old() is used to apply certain animations to outgoing elements. This means we are targeting the old element during a view transition.

.big-section {
  view-transition-name: faces;
}

/* view transition animation applied to only section with view transition name "faces" */
::view-transition-old(faces) {
  animation: scaling 2.5s;
}

@keyframes scaling {
  from {
    transform: scale(1.5);
  }
  to {
    transform: scale(1);
  }
}

Or if you want to target the whole page, including elements without a view transition name, use root:

.big-section {
  view-transition-name: faces;
}

/* view transition animation applied to the whole page */
::view-transition-old(root) {
  animation: scaling 2.5s;
}

@keyframes scaling {
  from {
    transform: scale(1.5);
  }
  to {
    transform: scale(1);
  }
}

::view-transition-old() is a pseudo-element of the view transition tree itself, not of DOM elements. You style it standalone, without element selectors. Using section::view-transition-old(*) is syntactically valid but semantically redundant. The view transition pseudo-elements exist outside the DOM hierarchy, so the section selector has no effect.

/* 👎 This will not work */
section::view-transition-old(*) {
  animation: flip 0.2s ease;
}

Instead, we give an element a view-transition-name which is then passed inside ::view-transition-old():

section {
  view-transition-name: big-section;
}

::view-transition-old(big-section) {
  animation: flip 0.2s ease;
}

Exit animation with ::view-transition-old()

One major difference between the ::view-transition-old() and ::view-transition-new() is that the former applies exit animations to outgoing elements, so to make the best use out of the animations for your old page, use ::view-transition-old(). Here’s an example specifically showing the exit animation for just the outgoing page.

::view-transition-group(.article-title-class) {
  animation-duration: 1.3s;
}
::view-transition-old(.article-title-class) {
  animation-name: fadeOut;
}

@keyframes fadeOut {
  to {
    opacity: 0;
    transform: rotate(120deg) translateY(-10px);
  }
}

The animation launches the header from 0deg  120deg while translating it to the -10px on the y-axis of the screen and fading it out. Again, this would only apply to the outgoing elements.

Live demo

View transition tree

To represent how the view transition works, the CSS specification provides what is known as the View Transition Tree and this gives us a visualization of a view transition containing all the view transition pseudo-elements:

::view-transition
├─ ::view-transition-group(name-1)
│  └─ ::view-transition-image-pair(name-1)
│     ├─ ::view-transition-old(name-1)
│     └─ ::view-transition-new(name-1)
├─ ::view-transition-group(name-2)
│  └─ ::view-transition-image-pair(name-2)
│     ├─ ::view-transition-old(name-2)
│     └─ ::view-transition-new(name-2)
│
/* ... and so on */

See the ::view-transition-old() pseudo-elements inside each ::view-transition-group()? Well, they will be created inside a view transition group for each view-transition-name we define.

So we can have as many ::view-transition-group()s, ::view-transition-image-pair()s, ::view-transition-new()s ::view-transition-old()s according to the number of view-transition-names present.

Animating pages like an image

Almost any animation can be applied to this, but remember how we mentioned at the start that the replaced element is an <img>? Well, this means properties like object-fit, clip-path, filter, transform, and opacity are perfect for styling::view-transition-old().

Here’s a demo showing different CSS properties used to animate page transitions using a single web-page:

That looks really clean! To achieve it, we’ll first need to give each image a view-transition-name.

img {
  /* ... */
  view-transition-name: match-element; /* Important to create a unique identifier for all images */
  view-transition-class: hero-img;
}

We use the match-element value inside view-transition-name for the <img> element to generate unique names for each <img>. match-element is a special keyword that tells the browser to automatically generate unique view-transition-name values for each element. For this to be useful, you pair it with view-transition-class to group those elements under a shared class for styling.

Then, we set the animation duration of each image to be 2.6s using the ::view-transition-group() pseudo-element.

/* Group animation duration */
::view-transition-group(.hero-img) {
  animation-duration: 2.6s;
}

Finally, we give the element a shrink animation when moving from the old state to the new state using the ::view-transition-old() pseudo-class.

/* Group animation duration */
::view-transition-group(.hero-img) {
  animation-duration: 2.6s;
}

/* OLD snapshot animation */
::view-transition-old(.hero-img) {
  animation-name: shrink;
}

@keyframes shrink {
  from {
    clip-path: circle(100% at 50% 50%);
    transform: scale(1);
  }
  to {
    clip-path: circle(0% at 50% 50%);
    transform: scale(0.9);
  }
}

Another important detail that makes this work is this JS code:

if (document.startViewTransition) {
  document.startViewTransition(() => {
    img.src = newSrc;
  });
} else {
  img.src = newSrc;
}

The code above checks if your browser supports view transitions, and if it does, it calls the startViewTranstion() function on the document object and passes in an anonymous function that applies a transition only when a new photo is passed, if the browser doesn’t support view transitions, it just changes the photo without any animation.

Transitions with multiple groups

To understand better what I mean, take the following example as a case study. We have several list items using the HTML unordered list tags <ul>, where we want to apply a simple view transition animation whenever we click the “Reverse list” button. That means we need to give each list item <li> a view transition name for this to work:

.item:nth-child(1) {
  view-transition-name: item-1;
}

.item:nth-child(2) {
  view-transition-name: item-2;
}

.item:nth-child(3) {
  view-transition-name: item-3;
}

/* ...  */

.item:nth-child(15) {
  view-transition-name: item-15;
}

Now, imagine this list wasn’t even 15, but 100! That would be a lot of view-transition-name declarations to write out. The match-element feature helps to eliminate the time spent writing unique view-transition-names because it tells the browser, “Hey, you do it for me.” So, instead, we can simply write:

.item {
  view-transition-name: match-element;
  view-transition-class: item;
}

The CSS view-transition-class is important because we want to style every single list item with the same animation. That means that, instead of having multiple ::view-transition-group()s for each ::view-transtion-name, we can just give all the elements a ::view-transition-class of item and call all of them out using the single class name of item:

.item {
  view-transition-name: match-element;
  view-transition-class: item;
}

/* When using view-transition-class, reference it 
   in ::view-transition-group() with a dot prefix 
   (e.g., .item), similar to class selectors,
   but in this specific context.
*/
::view-transition-group(.item) {
  animation-duration: 5.5s;
}

Finally, we have ::view-transition-old() applying animation on the previous snapshot when it transitions from the old state to the new state:

::view-transition-old(.item) {
  animation-name: fadeOut;
}

@keyframes fadeOut {
  to {
    opacity: 0;
  }
}

One last thing! In order to perform this on the same page on a button click, we have:

button.addEventListener("click", () => {
  document.startViewTransition(() => {
    reverse();
  });
});

Notice the document.startViewTransition() function. That applies the view transition to each list item when the “Reverse list” button is hit. The reverse() function reverses the list as we see fit, and it is placed inside the startViewTransition() function callback. In fact, without this, we would not trigger the view transition animation on the same page. So, take note of this!

With this, we can easily change the transition. For example, applying ::view-transition-new() with a fadeIn animation would look like this:

::view-transition-new(.item) {
  animation-name: fadeIn;
}

@keyframes fadeIn {
  from {
    opacity: 0;
    width: 0;
  }
}

Looks cool, right? That’s the power of view transitions!

Here’s another animation that uses ::view-transition-old(). We’re simply toggling between two cards smoothly using the blur() function in the CSS filter property and the translateY() function for added movement:

Hey, it’s even in the demo below. I customized our previous demo to have an option to randomize the order of items for more fun, so try it out!

Types of transitions on pages

There are two types of transitions that you can perform page-wise: (1) the in-page transition and (2) the cross-page transition.

For the in-page transition, you perform the transition with elements on the page. This can be simply done with view-transition-name, ::view-transition-old(), view-transition-class, and ::view-transition-group() as specified in Geoff’s article, “Toe Dipping Into View Transitions.”

The in-page transitions happen within the same document page, hence the name. Meanwhile, cross-page transitions happen between two pages of the same origin.

The major code difference between in-page transitions and cross-page transitions is that the @view-transition at-rule is not present in in-page transitions:

@view-transition {
  navigation: auto;
}

This single line of code enables cross-fading between two pages. When combined with the technique you learned in the “Animating pages like an image” section, you have this:

Live demo and GitHub repo for reference.

To make it, we first set the view-transition-name to match-element to create unique names for each progress bar on the pages marked with the .progress-bar class. We also set view-transition-class property to progress so we can select it later.

.progress-bar {
  view-transition-name: match-element;
  view-transition-class: progress;
}

Then, inside ::view-transition-group() and ::view-transition-new() we can specify the animation we want to use during the transition:

::view-transition-group(.progress) {
  animation-duration: 0.9s;
}

::view-transition-new(.progress) {
  animation-name: fadeIn;
}

@keyframes fadeIn {
  from {
    opacity: 0;
  }
}

Make sure to note that animations applied to the old elements are controlled by the ::view-transition-old() pseudo-element.

It doesn’t stop until it’s done

During an active view transition, the page is frozen and unresponsive until the animation completes. If you set very long animation durations, users cannot interact with the page during that time. This is why it’s important to keep transitions brief and respect prefers-reduced-motion.

Notice in this demo, when the animation duration is increased to 5.8s you cannot interact with any element on the page. The button click doesn’t even work.

This can be a frustrating experience for your users, so before you think of placing in those fancy long animations, maybe consider your users first and keep things brief and enjoyable.

Accessibility and ::view-transition-old()

Lastly, remember that animations can make users with vestibular disorders sick. So please, whenever you’re writing a view transition, remember to disable it for those who have set prefers-reduced-motion to reduce, or only apply it for those with no-preference:

.item {
  @media (prefers-reduced-motion: no-preference) {
    view-transition-name: match-element;
    view-transition-class: item;
  }
}

This way, the name would only be given when the user doesn’t have any preference set for reduced motion. And if it is set, the animation defaults to a simple fade in and out.

That said, short animations (under 200ms) might not be noticeable to some users, so there’s a balance between accessibility (reducing motion) and UX (providing feedback).

Specification

The CSS ::view-transition-old() pseudo-element is defined in the CSS View Transitions Module Level 1 specification, which is a stable standard.

It is also included in the  CSS View Transitions Module Level 2 specification with new features like match-element and view-transition-class. This is in Editor’s Draft, which is still in progress as of this writing.

Browser support

View transitions continue to evolve, with different features at different levels of standardization. Here’s the current support status:

The view-transition-class property:

And specifically, cross-document view transitions:

More Information