Skip to content

Latest commit

 

History

History
665 lines (464 loc) · 32.7 KB

explainer.md

File metadata and controls

665 lines (464 loc) · 32.7 KB

Contents

  1. Introduction.
  2. Why do we need a new API for this? - exploring the difficulties of achieving page transitions with existing APIs.
  3. MPA vs SPA solutions - how this API covers both same-document and cross-document transitions.
  4. Revisiting the cross-fade example - how to perform a cross-fade with this API.
  5. How the cross-fade worked - exploring the behind-the-scenes detail of the cross-fade.
  6. Simple customization - changing the default cross-fade.
  7. Transitioning multiple elements - moving parts of the page independently.
  8. Transitioning elements don't need to be the same DOM element - creating a transition where a thumbnail 'grows' into the main content.
  9. Transitioning elements don't need to exist in both states.
  10. Customizing the transition based on the type of navigation - e.g. creating 'reverse' transitions for 'back' traversals.
  11. Animating with JavaScript - because some transitions aren't possible with CSS alone.
  12. Compatibility with existing developer tooling.
  13. Compatibility with frameworks.
  14. Error handling - Ensuring DOM changes don't get lost, or stuck.
  15. Handling ink overflow - Dealing with things like box shadows.
  16. Full default styles & animation.
  17. Future work.
    1. Nested transition Groups - cases where the 'flattened' model isn't the best model.
    2. More granular style capture - cases where images aren't enough.
    3. Better pseudo-element selectors - because right now they kinda suck.
    4. Transitions targeted to a specific element - transitions that aren't the whole 'page'.
  18. Security/Privacy considerations.
  19. Interactivity and accessibility.

Introduction

Smooth page transitions can lower the cognitive load by helping users stay in context as they navigate from Page-A to Page-B, and reduce the perceived latency of loading.

hgnJfPFUbGlucFegEEtl.mp4

Why do we need a new API for this?

Typically, navigations on the web involve one document switching to another. Browsers try to eliminate an intermediate flash-of-white, but the switch between views is still sudden and abrupt. Until View Transitions, there was nothing developers could do about that without switching to an SPA model. This feature provides a way to create an animated transition between two documents, without creating an overlap between the lifetime of each document.

Although switching to an SPA allows developers to create transitions using existing technologies, such as CSS transitions, CSS animations, and the Web Animation API, it's something most developers and frameworks avoid, or only do in a limited fashion, because it's harder than it sounds.

Let's take one of the simplest transitions: a block of content that cross-fades between states.

To make this work, you need to have a phase where both the old and new content exist in the document at the same time. The old and new content will need to be in their correct viewport positions, which usually means they'll be overlaying each other, while maintaining layout with the rest of the page, so you'll probably need some form of wrapper to manage that. Another reason for the wrapper is to allow the two elements to correctly cross-fade using mix-blend-mode: plus-lighter. Then, the old content will fade from opacity: 1 to opacity: 0, while the new content fades from opacity: 0 to opacity: 1. Once that's complete, the old content is removed, perhaps along with some of the wrapper(s) that were used just for the transition.

However, there are a number of accessibility and usability pitfalls in this simple example. The phase where both contents exist at the same time creates an opportunity for users of assistive technology to get confused between the two. The transition is a visual affordance, it shouldn't be seen by things like screen readers. There's also an opportunity for the user to interact with the old content in a way the developer didn't prevent (e.g. pressing buttons). The second DOM change after the transition, where the old content is removed, can create more accessibility issues, as the DOM mutation can cause an additional aria-live announcement of the same content. It's also a common place for focus state to get confused, particularly in frameworks where the new content DOM used in the transition may not be the same DOM used in the final state (depending on how virtual DOMs are diffed, it may not realize it's the same content, particularly if containers have changed).

If the content is large, such as the main content, the developer has to handle differences in the root scroll position between the two states. At the very least, one of the pieces of content will need to be offset to counteract the scroll difference between the two, and unset once the transition is complete.

And this is just a simple cross-fade. Things get an order of magnitude more complicated when page components need to transition position between the states. Folks have created large complex plugins, built on top of even larger libraries, just to handle this small part of the problem. Even then, they don't handle cases where the element gets clipped by some parent, via overflow: hidden or similar. To overcome this, developers tend to pop the animating element out to the <body> so it can animate freely. To achieve that, the developer needs to alter their CSS so the element looks the same as a child of <body> as it does in its final place in the DOM. This discourages developers from using the cascade, and it plays badly with contextual styling features such as container queries.

If your site is an SPA, none of this is impossible, it's just really hard. With regular navigations (sometimes referred to as Multi-Page Apps, or MPAs), it is impossible.

The View Transitions feature follows the trend of transition APIs on platforms like Android, iOS/Mac and Windows, by allowing developers to continue to update page state atomically (either through DOM changes or cross-document navigations), while defining highly tailored transitions between the two states.

MPA vs SPA solutions

The current spec and implementation in Chrome focuses on SPA transitions. However, the model has also been designed to work with cross-document navigations. The specifics for cross-document navigations are covered here.

This doesn't mean we consider the MPA solution less important. In fact, developers have made it clear that it's more important. We have focused on SPAs due to the ease of prototyping, so those APIs have had more development. However, the overall model has been designed to work for MPAs, with a slightly different API around it.

Revisiting the cross-fade example

As described above, creating a cross-fade transition using existing platform features is more difficult than it sounds. Here's how to do it with View Transitions:

function spaNavigate(data) {
  // Fallback for browsers that don't support this API:
  if (!document.startViewTransition) {
    updateTheDOMSomehow(data);
    return;
  }

  document.startViewTransition(() => updateTheDOMSomehow(data));
}

(the API is described in detail in the next section)

And now there's a cross-fade between the states:

9rdbsCmBXKOYxOQNjBMI.mp4

Ok, a cross-fade isn't that impressive. Thankfully, transitions can be customized, but before we get to that, here's how this basic cross-fade worked:

How the cross-fade worked

Taking the code sample from above:

document.startViewTransition(() => updateTheDOMSomehow(data));

When document.startViewTransition() is called, the API captures the current state of the page. This includes taking a screenshot, which is async as it happens in the render steps of the event loop.

Once that's complete, the callback passed to document.startViewTransition() is called. That's where the developer changes the DOM.

Rendering is paused while this happens, so the user doesn't see a flash of the new content. Although, the render-pausing has an aggressive timeout.

Once the DOM is changed, the API captures the new state of the page, and 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 specific function of each part of this tree, and their default styles, is covered later in this document)

The ::view-transition sits in a top-layer, over everything else on the page.

::view-transition-old(root) is a screenshot of the old state, and ::view-transition-new(root) is a live representation of the new state. Both render as CSS replaced content.

The old image animates from opacity: 1 to opacity: 0, while the new image animates from opacity: 0 to opacity: 1, creating a cross-fade.

Once the animation is complete, the ::view-transition is removed, revealing the final state underneath.

Behind the scenes, the DOM just changed, so there isn't a time where both the old and new content existed at the same time, avoiding the accessibility, usability, and layout issues.

The animation is performed using CSS animations, so it can be customized with CSS.

Simple customization

All of the pseudo-elements above 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:

90h6Ppxza6oPqNiMpTPE.mp4

Or, more practically, here's an implementation of 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 the result:

BRT5dMgEzpixRmrVYKwN.mp4

Note: In this example, the animation always moves from right to left, which doesn't feel natural when clicking the back button. How to change the animation depending on the direction of navigation is covered later in the document.

Transitioning multiple elements

In the previous demo, the whole page is involved in the shared axis transition. But that doesn't seem quite right for the heading, as it slides out just to slide back in again.

To solve this, View Transitions allow you to extract parts of the page to animate independently, by assigning them a view-transition-name:

.header {
  view-transition-name: header;
  contain: layout;
}
.header-text {
  view-transition-name: header-text;
  contain: layout;
}

Independently transitioning elements needs to have layout or paint containment, and avoid fragmentation, so the element can be captured as a single unit.

The page will now be captured in three parts: The header, the header text, and the remaining page (known as the 'root').

ScreenFlow.mp4

This results in the following pseudo-element tree for the transition:

::view-transition
├─ ::view-transition-group(root)
│  └─ ::view-transition-image-pair(root)
│     ├─ ::view-transition-old(root)
│     └─ ::view-transition-new(root)
│
├─ ::view-transition-group(header)
│  └─ ::view-transition-image-pair(header)
│     ├─ ::view-transition-old(header)
│     └─ ::view-transition-new(header)
│
└─ ::view-transition-group(header-text)
   └─ ::view-transition-image-pair(header-text)
      ├─ ::view-transition-old(header-text)
      └─ ::view-transition-new(header-text)

The new pseudo-elements follow the same pattern as the first, but for a subset of the page. For instance, ::view-transition-old(header-text) is a 'screenshot' of the header text, and ::view-transition-new(header-text) is a live representation of the new header text. Although, in this case, the header text images are identical, but the element has changed position.

Without any further customization, here's the result:

eXu6vohojllPLNEQScjO.mp4

Note how the top header remains static.

As well as the cross-fade between the old-image and the new-image, another default animation transforms the ::view-transition-group from its before position to its after position, while also transitioning its width and height between the states. This causes the heading text to shift position between the states. Again, the developer can use CSS to customize this as they wish.

The high-level purpose of each pseudo-element:

  • ::view-transition-group - animates size and position between the two states.
  • ::view-transition-image-pair - provides blending isolation, so the two images can correctly cross-fade.
  • ::view-transition-old and ::view-transition-new - the visual states to cross-fade.

The full default styles and animations of the pseudo-elements are covered later in the document.

Transitioning elements don't need to be the same DOM element

In the previous examples, view-transition-name was used 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 instance, the main video embed can be given a view-transition-name:

.full-embed {
  view-transition-name: full-embed;
  contain: layout;
}

Then, when the thumbnail is clicked, it can be given the same view-transition-name, just for the duration of the transition:

thumbnail.onclick = () => {
  thumbnail.style.viewTransitionName = "full-embed";

  document.startViewTransition(() => {
    thumbnail.style.viewTransitionName = "";
    updateTheDOMSomehow();
  });
};

And the result:

283vqtaDXSaGRTn5nEEn.mp4

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.

This is useful for cases like above where one element is 'turning into' another, but also for cases where a framework creates a new Element for something even though it hasn't really changed, due to a virtual DOM diffing mismatch.

Also, this model is essential for MPA navigations, where all elements across the state-change will be different DOM elements.

Transitioning elements don't need to exist in both states

It's valid for some transition elements to only exist on one side of the DOM change, such as a side-bar that doesn't exist on the old page, but exists in the new page.

For example, if an element only exists in the 'after' state, then it won't have a ::view-transition-old, and its ::view-transition-group won't animate by default, it'll start in its final position.

Customizing the transition based on the type of navigation

In some cases, the elements captured, and the resulting animations, should be different depending on the source & target page, and also different depending on the direction of navigation.

hgnJfPFUbGlucFegEEtl.mp4

In this example, the transition between the thumbnails page and the video page is significantly different to the transition between video pages. Also, animation directions are reversed when navigating back.

There isn't a specific feature for handling this. Developers can add class names to the document element, allowing them to write selectors that change which elements get a view-transition-name, and which animations should be used.

In particular, the Navigation API makes it easy to distinguish between a back vs forward traversal/navigation.

Animating with JavaScript

The ready promise on ViewTransition returned by document.startViewTransition() fulfills when both states have been captured and the pseudo-element tree has been successfully built. This provides developers with a point where they can animate those pseudo-elements with the Web Animation API.

For example, if the developer wanted to create a circular-reveal animation from the point of the last click:

let lastClick;
addEventListener("click", (event) => (lastClick = event));

async function spaNavigate(data) {
  // Fallback for browsers that don't support this API:
  if (!document.startViewTransition) {
    updateTheDOMSomehow(data);
    return;
  }

  const transition = document.startViewTransition(() => {
    // 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.sqrt(
      Math.max(x, innerWidth - x) ** 2 + Math.max(y, innerHeight - y) ** 2
    );

    updateTheDOMSomehow(data);
  });

  animateTransition(transition);

  // spaNavigate should resolve when the DOM updates,
  // not when the transition finishes.
  return transition.updateCallbackDone;
}

async function animateTransition(transition) {
  await transition.ready;

  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)",
    }
  );

  return transition.finished;
}

And here's the result:

MrrqwatxWSPdobfDR1Qo.mp4

Cross-document same-origin transitions

This section has moved to its own explainer.

Compatibility with existing developer tooling

Since this feature is built on existing concepts such as pseudo-elements and CSS animations, tooling for this feature should fit in with existing developer tooling.

In Chrome's experimental implementation, the pre-existing animation panel can be used to debug transitions, and the pseudo-elements are exposed in the elements panel.

DMH7qPqMszyVbTYOA2zd.mp4

Compatibility with frameworks

The DOM update can be async, to cater for frameworks that queue state updates behind microtasks. This is signaled by returning a promise from the document.startViewTransition() callback, which is easily achieved with an async function:

document.startViewTransition(async () => {
  await updateTheDOMSomehow();
});

However, the pattern above assumes the developer is in charge of DOM updates, which isn't the case with most web frameworks. To assess the compatibility of this API with frameworks, the demo site featured in this explainer was built using Preact, and uses a React-style hook to wrap the above API and make it usable with React/Preact.

As long as the framework provides a notification when the DOM is updated, which they already do to allow custom handling of elements, the transition API can be made to work with the framework.

Error handling

This feature is built with the view that a transition is an enhancement to a DOM change. For example:

document.startViewTransition(async () => {
  await updateTheDOMSomehow();
});

The API could discover an error before calling the document.startViewTransition() callback, meaning the transition cannot happen. For example, it may discover two elements with the same view-transition-name, or one of the transition elements is fragmented in a way that's incompatible with the API. In this case we still call the document.startViewTransition() callback, because the DOM change is more important than the transition, and being unable to create a transition is not a reason to prevent the DOM change.

However, if a transition cannot be created, the ready promise on the returned ViewTransition` will reject.

Error detection is also the reason why document.startViewTransition() takes a callback, rather than a model where the developer calls a method to signal when the DOM is changed:

// Not the real API, just an alternative example:
const transition = new ViewTransition();
await transition.prepare();
await updateTheDOMSomehow();
transition.ready();

In a model like the one above, if updateTheDOMSomehow() throws, transition.ready would never be called, so the API would be in a state where it doesn't know if DOM change failed, or if it's just taking a long time. The callback pattern avoids this gotcha – we get to see the thrown error, and abandon the transition quickly.

The Navigation API and Web Locks API use this same pattern for the same reason.

Handling ink overflow

Elements can paint outside of their border-box for a number of reasons, such as box-shadow.

The ::view-transition-old and ::view-transition-new will be the border box size of the original element, but the full ink overflow will be included in the image. This is achieved via object-view-box, which allows replaced elements to paint outside their bounds.

Animating width and height

The ::view-transition-group animates its width and height by default, which usually means the animations will run on the main thread.

However, width and height was deliberately chosen for developer convenience, as it plays well with things like object-fit and object-position.

gXiaS9IpE70fnv4kkrK5.mp4

In this example, a 4:3 thumbnail transitions into a 16:9 main image. This is relatively easy with object-fit, but would be complex using only transforms.

Due to the simple nature of these pseudo-element trees, these animations should be able to run off the main thread. However, if the developer adds something that requires layout, such as a border, the animation will fall back to main thread.

Full default styles & animation

::view-transition

Default styles:

::view-transition {
  // Aligns this element with the "snapshot viewport". This is the viewport when all retractable
  // UI (like URL bar, root scrollbar, virtual keyboard) are hidden.
  position: fixed;
  top: -10px;
  left: -15px;
}

::view-transition-group(*)

Default styles:

::view-transition-group(*) {
  /*= Styles for every instance =*/
  position: absolute;
  top: 0px;
  left: 0px;
  will-change: transform;
  pointer-events: auto;

  /*= Styles generated per instance =*/

  /* Dimensions of the new element */
  width: 665px;
  height: 54px;

  /* A transform that places it in the viewport position of the new element. */
  transform: matrix(1, 0, 0, 1, 0, 0);

  writing-mode: horizontal-tb;
  animation: 0.25s ease 0s 1 normal both running
    page-transition-group-anim-main-header;
}

Default animation:

@keyframes page-transition-group-anim-main-header {
  from {
    /* Dimensions of the old element */
    width: 600px;
    height: 40px;

    /* A transform that places it in the viewport position of the old element. */
    transform: matrix(2, 0, 0, 2, 0, 0);
  }
}

::view-transition-image-pair(*)

Default styles:

::view-transition-image-pair(*) {
  /*= Styles for every instance =*/
  position: absolute;
  inset: 0px;

  /*= Styles generated per instance =*/
  /* Set if there's an old and new image, to aid with cross-fading.
     This is done conditionally as isolation has a performance cost. */
  isolation: isolate;
}

Default animation: none.

::view-transition-old(*)

This is a replaced element displaying the capture of the old element, with a natural aspect ratio of the old element.

::view-transition-old(*) {
  /*= Styles for every instance =*/
  position: absolute;
  inset-block-start: 0px;
  inline-size: 100%;
  block-size: auto;
  will-change: opacity;

  /*= Styles generated per instance =*/

  /* Set if there's an old and new image, to aid with cross-fading.
     This is done conditionally as isolation has a performance cost. */
  mix-blend-mode: plus-lighter;

  /* Allows the image to be the layout size of the element,
     but allow overflow (to accommodate ink-overflow)
     and underflow (cropping to save memory) in the image data. */
  object-view-box: inset(0);

  animation: 0.25s ease 0s 1 normal both running blink-page-transition-fade-out;
}

Note that the block-size of this element is auto, so it won't stretch the image as the container changes height. The developer can change this if they wish.

Default animation:

@keyframes page-transition-fade-out {
  from {
    opacity: 0;
  }
}

::view-transition-new(*)

@keyframes page-transition-fade-in {
  to {
    opacity: 0;
  }
}

Future work

There are parts to this feature that we're actively thinking about, but aren't fully designed.

Nested transition groups

In the current design, each ::view-transition-group is a child of the ::view-transition. This works really well in most cases, but not all:

ScreenFlow.mp4

The element moving from one container to the other benefits from the flat arrangement of ::view-transition-groups, as it doesn't get clipped by the parent. However, the elements that remain in the container do benefit from the clipping provided by the parent.

The rough plan is to allow nesting via an opt-in (all API names used here are for entertainment purposes only):

.container {
  view-transition-name: container;
  contain: paint;
}
.child-item {
  view-transition-name: child-item;
  contain: layout;
  page-transition-style-or-whatever: nested;
}

With this opt in, rather than the containers being siblings:

::view-transition
├─ …
├─ ::view-transition-group(container)
│  └─ ::view-transition-image-pair(container)
│     └─ …
└─ ::view-transition-group(child-item)
   └─ ::view-transition-image-pair(child-item)
      └─ …

…the child-item would be nested in its closest parent that's also a transition element:

::view-transition
├─ …
└─ ::view-transition-group(container)
   ├─ ::view-transition-image-pair(container)
   │  └─ …
   └─ ::view-transition-group(child-item)
      └─ ::view-transition-image-pair(child-item)
         └─ …

More granular style capture

By default, elements are captured as images. This means if a rounded box is transitioning into a different size box with the same border-radius, there'll be some imperfect scaling of the corners during the transition.

This often isn't as bad as it sounds in practice, particularly in fast transitions. And, developers can build custom animations with clip-paths to work around the issue in some cases. However, we are considering a different opt-in capture mode, where the computed styles of the transition elements are captured, allowing for transitions that involve layout.

In this mode, the content of the element would still be an image, but the element itself would have things like the border-radius and box-shadow copied over, rather than being baked into an image.

However, since these animations would involve layout, they would need to run on the main thread.

Better pseudo-element selectors

This feature makes use of nested pseudo-elements. It isn't the first feature to do that, as there's also ::before::marker, but this feature has more than two levels of nesting.

Right now, all pseudo elements are accessed from the root element, which doesn't really express their nesting. However, if the nesting was fully expressed, you'd end up with selectors like:

::view-transition-group(foo)::image-wrapper::old-image {
  /* … */
}

We have proposed a new combinator to make it easier to select descendant pseudo elements w3c/csswg-drafts#7346.

::view-transition-group(foo) :>> old-image {
  /* … */
}

This will play well with CSS nesting:

::view-transition-group(foo) {
  & :>> old-image {
    /* … */
  }
  & :>> new-image {
    /* … */
  }
}

Transitions targeted to a specific element

In the current design, the transition acts across the whole document. However, developers have expressed interest in using this system, but limited to a single element. For example, allowing two independent components to perform transitions.

This is being discussed in #52 and a rough proposal is here.

Security/Privacy considerations

The security considerations below cover same-origin transitions.

  • Script can never read pixel content in the images. This is necessary since the document may embed cross-origin content (iframes, CORS resources, etc.) and multiple restricted user information (visited links history, dictionary used for spell check, etc.)
  • If an element is captured as a 'computed style + content image', any external resources specified on the container, such as background images, will be re-fetched in the context of the new page to account for differences in sandboxing.

Cross-origin transitions aren't yet defined, but are likely to be heavily restricted.

Interactivity and accessibility

  • Page transitions are a purely visual affordance. In terms of interactivity, transition elements will behave like divs regardless of the original element. Developers could break this intent by adding interactivity directly to the transition element, e.g. by deliberately adding a tabindex attribute. But this isn't recommended.
  • The page transition stage will be hidden from assistive technologies such as screen readers.
  • The duration for which DOM rendering is suppressed, to allow an author to asynchronously switch to the new DOM, input processing is also paused. This is necessary since the visual state presented to the user is inconsistent with the DOM state used for hit-testing.