jakearchibald / navigation-transitions

335 stars 11 forks source link

The problem

If you want to transition between pages, your current option is to fetch the new page with JavaScript, update the URL with [pushState](https://developer.mozilla.org/en-US/docs/Web/API/History_API#The_pushState()_method), and animate between the two.

Having to reimplement navigation for a simple transition is a bit much, often leading developers to use large frameworks where they could otherwise be avoided. This proposal provides a low-level way to create transitions while maintaining regular browser navigation.

Goals

Experiments, talks & reading materials

Web Navigation Transitions (2014): CSS-based experiment by Google Chrome team

Web Navigation Transitions (2015): CSS-based proposal by Chris Lord (Mozilla, Firefox OS)

Web Navigation Transitions (2016): New proposal by Jake Archibald (Google Chrome)

API sketch

window.addEventListener('navigate', event => {
  // …
});

The navigate event fires when the document is being navigated in a way that would replace the current document.

Note: The same-origin restrictions are to avoid new URL leaks and timing attacks.

Simple cross-fade transition

window.addEventListener('navigate', event => {
  event.transitionUntil(
    event.newWindow.then(newWin => {
      if (!newWin) return;

      // assuming newWin.document.interactive means DOM ready
      return newWin.document.interactive.then(() => {
        return newWin.document.documentElement.animate([
          {opacity: 0}, {opacity: 1}
        ], 1000).finished;
      });
    })
  );
});

Slide-in/out transition

window.addEventListener('navigate', event => {
  if (event.reason == 'reload') return;

  const fromRight = [
    {transform: 'translate(100%, 0)'},
    {transform: 'none'}
  ];

  const toLeft = [
    {transform: 'none'},
    {transform: 'translate(-100%, 0)'}
  ];

  const fromLeft = toLeft.slice().reverse();
  const toRight = fromRight.slice().reverse();

  event.transitionUntil(
    event.newWindow.then(newWin => {
      if (!newWin) return;

      return newWin.document.interactive.then(() => {
        return Promise.all([
          newWin.document.documentElement.animate(
            event.reason == 'back' ? fromLeft : fromRight, 500
          ).finished,
          document.documentElement.animate(
            event.reason == 'back' ? toRight : toLeft, 500
          ).finished
        ]);
      });
    })
  );
});

Immediate slide-in/out transition

The above examples don't begin to animate until the new page has fetched and become interactive. That's ok, but this API allows the current page to transition while the new page is being fetched, improving the perception of performance:

window.addEventListener('navigate', event => {
  if (event.reason == 'reload') return;

  const newURL = new URL(event.url);

  if (newURL.origin !== location.origin) return;

  const documentRect = document.documentElement.getBoundingClientRect();

  // Create something that looks like the shell of the new page
  const pageShell = createPageShellFor(event.url);
  document.body.appendChild(pageShell);

  const directionMultiplier = event.reason == 'back' ? -1 : 1;

  pageShell.style.transform = `translate(${100 * directionMultiplier}%, ${-documentRect.top}px)`;

  const slideAnim = document.body.animate({
    transform: `translate(${100 * directionMultiplier}%, 0)`
  }, 500);

  event.transitionUntil(
    event.newWindow.then(newWin => {
      if (!newWin) return;

      return slideAnim.finished.then(() => {
        return newWin.document.documentElement
          .animate({opacity: 0}, 200).finished;
      });
    })
  );
});

Rendering & interactivity

During the transition, the document with the highest z-index on the documentElement will render on top. If z-indexes are equal, the entering document will render on top. Both documentElements will generate stacking contexts.

If the background of html/body is transparent, the underlying document will be visible through it. Beneath both documents is the browser's default background (usually white).

During the transition, the render-box of the documents will be clipped to that of the viewport size. This means html { transform: translate(0, -20px); } on the top document will leave a 20-pixel gap at the bottom, through which the bottom document will be visible. After the transition, rendering switches back to using the regular model.

We must guarantee that the new document doesn't visibly appear until event.newWindow's reactions have completed.

As for interactivity, both documents will be at least scrollable, although developers could prevent this using pointer-events: none or similar.

Apologies for the hand-waving.

Place within the navigation algorithm

It feels like the event should fire immediately after step 10 of navigate. If transitionUntil is called, the browser would consider the pages to be transitioning.

The rest of the handling would likely be in the "update the session history with the new page" algorithm. The unloading of the current document would be delayed but without delaying the loading of the new document.

Yep, more hand-waving.

Potential issues & questions