Back to Table of Contents

The View Transitions API

What CoPilot thinks a cat looks like

By Jesse Pence

Introduction

Animations have been a part of the web experience for a long time, but it has never been particularly easy to create an immersive experience. While we have come a long way from the days of the marquee tag and animated “under construction” GIFs, the web is still a bit behind its native peers. It is simple enough to move things around on a single web page with CSS, but it gets a lot harder when you try to animate between multiple pages. You end up using a lot of JavaScript if you want to achieve any kind of continuity.

So, the Chrome team have developed a new web standard called the View Transitions API to try to make things easier. While it seamlessly integrates into single-page applications, it also has some exciting implications for multi-page applications. But, let’s start by discussing the basics of CSS animations and transitions.

Animations and Transitions

Generally, if you want to transition between multiple views in a smooth way on the web, you need to use JavaScript. For many, this means a framework like React. Like so many other complexities of the web, this goes back to the essential fact that the browser reads websites like a document— from top to bottom.

For each page, the browser parses an HTML document by analyzing its syntax. First, the head tags tell the browser how the document is formatted and what resources it needs to load. The browser then halts other activities to load these resources — a process known as blocking the main thread. Then, the body tags tell the browser how to render the page. It constructs new DOM and CSSOM trees to represent the document’s structure, and then it renders the page by computing the layout, applying the styles, and finally painting the pixels on the screen.

Essentially, every time you navigate to a new page, the browser completely destroys the old one and creates one from scratch with the new data. In reality, it may hold resources from the old page in memory for some time. The important thing is that the browser assumes that nothing from the previous page is relevant to the new one unless you override that behavior with client-side routing or caching headers.

Attempting to avoid this expensive process for each new page is one of the many reasons why Single Page Applications grew in prominence. To maximize browser performance and enhance user experience, developers will animate between states on the same page to give the user the illusion of navigating to a new page. Let’s take a quick look at some demos of the traditional way of doing things. This first demo just shows the basic idea of tying a DOM element to a state. As we’ve seen, that’s basically what a route is— A collection of state tied to a URL.

simple-animation
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Animating an Element</title>
    <style>
      .box {
        transition: left 2s ease-in-out, font-size 1s ease-in-out,
          border-radius 2s ease-in-out, background-color 1s ease-in-out;
        position: relative;
        left: 0;
        width: 200px;
        height: 200px;
        background-color: #f00;
        border-radius: 0;
        font-size: 1em;
        text-align: center;
        padding-top: 100px;
      }
      .box.state-2 {
        left: 200px;
        font-size: 2em;
        border-radius: 50%;
        background-color: #0f0;
      }
    </style>
  </head>
  <body>
    <button id="toggle-btn">Want a change in scenery?</button>
    <div id="box" class="box">
      <p id="text">This is your home.</p>
    </div>
    <script>
      const button = document.getElementById("toggle-btn")
      const box = document.getElementById("box")
      const text = document.getElementById("text")

      button.addEventListener("click", function () {
        if (box.classList.contains("state-2")) {
          box.classList.remove("state-2")
          text.innerText = "This is your home."
          button.innerText = "You're home! Wanna Leave?"
        } else {
          box.classList.add("state-2")
          text.innerText = "This is somewhere else."
          button.innerText = "Go back home!"
        }
      })
    </script>
  </body>
</html>

Check out this pin by Jesse Pence (@jesse-pence) on CodePen.

This demo, along with many of the others, leverages the power of CSS Transitions. Generally, they are an easy way of toggling your styles between various states smoothly. Usually, this will be between two, pre-defined states.

However, you can also write dynamic transitions that change over time based on user interaction, but it can get a bit complex as you juggle between the classes. You have to keep it clear to the browser that it is the same element so it can interpolate between the two states. So, it can get pretty difficult if you want to define multiple intermediate steps.

What if you want the animation to be a bit more complex? Well, that’s where CSS Animations come in. You can define keyframes which are basically just ways to say when and how you want an element to change. Then, you can apply these keyframes to as many elements as you want.

Our next demo is a bit of a playground. The button is a simple transition between three states, but the main animation is a bit more exciting. I used CSS variables to allow you to control a CSS Animation that will play when the button is clicked. CSS Animations are a bit more complex than transitions, but they are also much more powerful and customizable.

animation-sandbox
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Animating an Element</title>
    <style>
      :root {
        --animation-duration: 2s;
        --animation-direction: alternate;
        --animation-fill-mode: none;
        --iteration-count: infinite;
        --timing-function: ease-in-out;
        --left: 200px;
        --width: 200px;
        --height: 200px;
        --background-color: #f00;
        --border-radius: 0;
      }

      .box {
        animation-name: box;
        animation-duration: var(--animation-duration);
        animation-direction: var(--animation-direction);
        animation-fill-mode: var(--animation-fill-mode);
        animation-iteration-count: var(--iteration-count);
        animation-timing-function: var(--timing-function);
        position: relative;
        width: var(--width);
        height: var(--height);
        background-color: var(--background-color);
        border-radius: var(--border-radius);
        font-size: 1em;
        text-align: center;
        padding-top: 100px;
      }

      @keyframes box {
        0% {
          left: 0;
        }
        100% {
          left: var(--left);
        }
      }

      main {
        display: grid;
        grid-template-columns: minmax(0, 1fr) minmax(0, 3fr);
      }

      .controls {
        display: flex;
        flex-direction: column;
        justify-content: start;
        align-items: flex-start;
      }

      label {
        font-weight: bold;
      }

      button {
        transition: all 0.5s ease;
        background-color: blue;
        color: #fff;
        border: none;
        padding: 1em;
        border-radius: 5px;
      }

      button:hover {
        background-color: green;
        transform: scale(1.1);
        box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);
      }

      button:active {
        background-color: red;
        transform: scale(0.9);
        box-shadow: 0 3px 6px rgba(0, 0, 0, 0.2);
      }
    </style>
  </head>
  <body>
    <main>
      <div class="controls">
        <button id="toggle-btn">Trigger Animation</button>
        <h3>Animation Properties</h3>
        <label for="animation-duration">Animation Duration</label>
        <input
          id="animation-duration"
          type="range"
          min="0"
          max="10"
          step="0.1"
          value="2"
        />
        <span id="duration-value">2s</span>
        <label for="iteration-count">Animation Iteration Count</label>
        <input
          id="iteration-count"
          type="range"
          min="0"
          max="10"
          step="1"
          value="1"
          disabled
        />
        <label for="infinity">Infinity?</label>
        <input checked id="infinity" type="checkbox" />
        <span id="iteration-value">Current: Infinite</span>
        <label for="animation-direction">Animation Direction</label>
        <select id="animation-direction">
          <option value="alternate">Alternate</option>
          <option value="normal">Normal</option>
          <option value="reverse">Reverse</option>
          <option value="alternate-reverse">Alternate Reverse</option>
        </select>
        <label for="animation-fill-mode">Animation Fill Mode</label>
        <select id="animation-fill-mode">
          <option value="none">None</option>
          <option value="forwards">Forwards</option>
          <option value="backwards">Backwards</option>
          <option value="both">Both</option>
        </select>
        <label for="timing-function">Timing Function</label>
        <select id="timing-function">
          <option value="ease-in-out">Ease In Out</option>
          <option value="linear">Linear</option>
          <option value="ease">Ease</option>
          <option value="ease-in">Ease In</option>
          <option value="ease-out">Ease Out</option>
          <option value="cubic-bezier(0.1, 0.7, 1.0, 0.1)">
            Weird Cubic Bezier
          </option>
        </select>
        <label for="left">Pixels to Move from Left</label>
        <input id="left" type="range" min="0" max="1000" step="1" value="200" />
        <h3>Element Properties</h3>
        <label for="width">Width</label>
        <input
          id="width"
          type="range"
          min="0"
          max="1000"
          step="1"
          value="200"
        />
        <label for="height">Height</label>
        <input
          id="height"
          type="range"
          min="0"
          max="1000"
          step="1"
          value="200"
        />
        <label for="background-color">Background Color</label>
        <input id="background-color" type="color" value="#ff0000" />
        <label for="border-radius">Border Radius</label>
        <input
          id="border-radius"
          type="range"
          min="0"
          max="100"
          step="1"
          value="0"
        />
      </div>
      <div id="box" class="box">
        <p id="text">Mold me to your will</p>
      </div>
    </main>
    <script>
      const button = document.getElementById("toggle-btn")
      const box = document.getElementById("box")
      const text = document.getElementById("text")
      const animationDuration = document.getElementById("animation-duration")
      const animationDirection = document.getElementById("animation-direction")
      const animationFillMode = document.getElementById("animation-fill-mode")
      const iterationCount = document.getElementById("iteration-count")
      const infinity = document.getElementById("infinity")
      const timingFunction = document.getElementById("timing-function")
      const durationValue = document.getElementById("duration-value")
      const iterationValue = document.getElementById("iteration-value")
      const width = document.getElementById("width")
      const height = document.getElementById("height")
      const backgroundColor = document.getElementById("background-color")
      const borderRadius = document.getElementById("border-radius")
      const left = document.getElementById("left")
      const root = document.documentElement

      animationDuration.addEventListener("input", function () {
        root.style.setProperty(
          "--animation-duration",
          animationDuration.value + "s"
        )
        durationValue.innerHTML = animationDuration.value + "s"
      })

      animationDirection.addEventListener("input", function () {
        root.style.setProperty(
          "--animation-direction",
          animationDirection.value
        )
      })

      animationFillMode.addEventListener("input", function () {
        root.style.setProperty("--animation-fill-mode", animationFillMode.value)
      })

      iterationCount.addEventListener("input", function () {
        if (infinity.checked) {
          root.style.setProperty("--iteration-count", "infinite")
          iterationValue.innerHTML = "To Infinity-- and Beyond!"
        } else {
          root.style.setProperty("--iteration-count", iterationCount.value)
          iterationValue.innerHTML = `Current: ${iterationCount.value}`
        }
      })

      infinity.addEventListener("input", function () {
        if (infinity.checked) {
          root.style.setProperty("--iteration-count", "infinite")
          iterationValue.innerHTML = "To Infinity-- and Beyond!"
          iterationCount.disabled = true
        } else {
          root.style.setProperty("--iteration-count", iterationCount.value)
          iterationValue.innerHTML = `Current: ${iterationCount.value}`
          iterationCount.disabled = false
        }
      })

      timingFunction.addEventListener("input", function () {
        root.style.setProperty("--timing-function", timingFunction.value)
      })

      left.addEventListener("input", function () {
        root.style.setProperty("--left", left.value + "px")
      })

      width.addEventListener("input", function () {
        root.style.setProperty("--width", width.value + "px")
      })

      height.addEventListener("input", function () {
        root.style.setProperty("--height", height.value + "px")
      })

      backgroundColor.addEventListener("input", function () {
        root.style.setProperty("--background-color", backgroundColor.value)
      })

      borderRadius.addEventListener("input", function () {
        root.style.setProperty("--border-radius", borderRadius.value + "%")
      })

      button.addEventListener("click", function () {
        // first, we remove the animation from the element
        box.style.animation = "none"

        // then we force the browser to reflow the element
        setTimeout(function () {
          // by simply changing the animation name to an empty string
          box.style.animation = ""
          // it doesn't find anything in the JS code, so it falls back to the CSS
        })
      })
    </script>
  </body>
</html>

Check out this pin by Jesse Pence (@jesse-pence) on CodePen.

Hopefully, this helps you see how much more flexible CSS animations are than transitions. While this demo animation simply transitions from one state to another, you can construct intricate animations with numerous transitional phases between the start and end points. This one can even repeat infinitely.

I’m using CSS variables here. CSS variables are great because they can be shared between multiple elements to change several things at once. Once you realize how easy it is to manipulate these values with some simple JavaScript, the possibilities are endless.

Now that we’ve covered the basics of CSS animations, let’s put them to use in a somewhat real-world example. In this section, we’ll build a simple image gallery where you can activate an image to fill the screen. Our main imaginary constraint is that we have no way of knowing how many images will be in the gallery or what size they will be, and we can’t change the stylesheet so we have to do everything in JavaScript.

We’ll be converting this to the new View Transitions API later. First, I want to show how things could be done without it. Be patient! Or, skip to the next section. If you’re still with me, let’s start with a simple transition. As we discussed, CSS transitions are great for situations when you are simply toggling between two states.

kitten-gallery-one
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Animated Image Gallery</title>
    <style>
      body {
        margin: 0;
        overflow: hidden;
        background: #000;
      }

      #gallery {
        display: flex;
        justify-content: center;
        align-items: center;
        height: 100vh;
      }

      .gallery-item {
        width: 200px;
        height: 200px;
        cursor: pointer;
        transition: all 1s ease-in-out;
      }

      .gallery-item img {
        width: 100%;
        height: 100%;
        object-fit: contain;
      }

      .gallery-item.active {
        width: 100vw;
        height: 75vh;
      }

      .gallery-item.active img {
        width: auto;
        height: auto;
        max-width: 100%;
        max-height: 100%;
        object-fit: cover;
      }
    </style>
  </head>
  <body>
    <div id="gallery"></div>
    <script>
      const images = [
        "https://placekitten.com/200/200",
        "https://placekitten.com/300/300",
        "https://placekitten.com/400/400",
        "https://placekitten.com/500/500",
        "https://placekitten.com/600/600",
        "https://placekitten.com/700/700",
        "https://placekitten.com/800/800",
        "https://placekitten.com/900/900",
      ]

      const gallery = document.querySelector("#gallery")

      images.forEach((src) => {
        const item = document.createElement("div")
        item.classList.add("gallery-item")
        const img = document.createElement("img")
        img.src = src
        img.alt = "cute kitten"
        item.appendChild(img)
        gallery.appendChild(item)
      })

      const galleryItems = document.querySelectorAll(".gallery-item")
      galleryItems.forEach((item, index) => {
        item.addEventListener("click", () => {
          if (!item.classList.contains("active")) {
            item.classList.add("active")
          } else {
            item.classList.remove("active")
          }
        })
      })
    </script>
  </body>
</html>

Check out this pin by Jesse Pence (@jesse-pence) on CodePen.

Just like the very first example, we don’t have to code any specific animation code to make the transition work. The browser simply interpolates between the two states. But, with this simplicity comes a lot of limitations.

Making this demo was a process of trial and error. It was very difficult to get the image to display with the correct image aspect ratio, centered on the screen, as large as possible, and with a smooth transition. Often, when I would get it centered, I would lose the nice transition or the correct aspect ratio.

I would love to see some other attempts at this because I know that I’m not the best at CSS. But, I think that this is a good example of how difficult it can be to get a perfect CSS transition. So, let’s see how this demo works with some custom animations instead.

kitten-gallery-two
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Animated Image Gallery</title>
    <style>
      body {
        margin: 0;
        overflow: hidden;
      }

      #gallery {
        display: flex;
        justify-items: center;
        align-items: center;
        height: 100vh;
        background: #000;
      }

      .gallery-item {
        width: 200px;
        height: 200px;
        cursor: pointer;
      }

      .gallery-item.active {
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        height: max-content;
        width: max-content;
      }

      .gallery-item img {
        width: 100%;
        height: 100%;
        object-fit: contain;
      }

      .gallery-item.active img {
        width: auto;
        height: auto;
        object-fit: cover;
        max-width: 100%;
        max-height: 100%;
      }

      .slideOut {
        animation: slide 1s ease-in-out forwards;
      }

      .slideIn {
        animation: slide 1s ease-in-out forwards reverse;
      }

      .fadeIn {
        animation: fadeIn 1s ease-in-out forwards;
      }

      .fadeOut {
        animation: fadeOut 1s ease-in-out forwards;
      }

      @keyframes slide {
        to {
          transform: translateX(100%);
          opacity: 0;
        }
      }

      @keyframes fadeIn {
        0% {
          clip-path: polygon(0 0, 0 0, 0 0, 0 100%);
        }
        25% {
          filter: blur(0);
        }
        50% {
          transform: scale(0.5) translate(-50%, -50%);
          clip-path: polygon(0 0, 100% 0, 0 100%, 0 100%);
          filter: blur(20px);
        }
        75% {
          filter: blur(0);
        }
        100% {
          clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
          transform: scale(1) translate(-50%, -50%);
        }
      }
      @keyframes fadeOut {
        0% {
          opacity: 1;
          transform: scale(1);
        }
        70% {
          transform: scale(0.2);
          opacity: 0;
        }
        100% {
          opacity: 1;
          transform: scale(1);
        }
      }
    </style>
  </head>
  <body>
    <div id="gallery"></div>
    <div id="overlay"></div>
    <script>
      const images = [
        "https://placekitten.com/200/200",
        "https://placekitten.com/300/300",
        "https://placekitten.com/400/400",
        "https://placekitten.com/500/500",
        "https://placekitten.com/600/600",
        "https://placekitten.com/700/700",
        "https://placekitten.com/800/800",
        "https://placekitten.com/900/900",
      ]

      const gallery = document.querySelector("#gallery")

      images.forEach((src) => {
        const item = document.createElement("div")
        item.classList.add("gallery-item")
        const img = document.createElement("img")
        img.src = src
        img.alt = "cute kitten"
        item.appendChild(img)
        gallery.appendChild(item)
      })

      function animateGalleryItem(item, animation) {
        item.classList.add(animation)
        item.addEventListener("animationend", () => {
          item.classList.remove(animation)
        })
      }

      const galleryItems = document.querySelectorAll(".gallery-item")
      let hiddenItems = []
      let activeItem = null

      galleryItems.forEach((item) => {
        item.addEventListener("click", () => {
          if (activeItem) {
            activeItem.classList.remove("active")
            animateGalleryItem(activeItem, "fadeOut")
            hiddenItems = [...galleryItems].filter(
              (i) => i !== activeItem && i !== item
            )
            hiddenItems.forEach((i) => (i.style.display = "block"))
            hiddenItems.forEach((i) => animateGalleryItem(i, "slideIn"))
            activeItem = null
          } else {
            activeItem = item
            activeItem.classList.add("active")
            animateGalleryItem(activeItem, "fadeIn")
            hiddenItems = [...galleryItems].filter((i) => i !== item)
            hiddenItems.forEach((i) => animateGalleryItem(i, "slideOut"))
            setTimeout(() => {
              hiddenItems.forEach((i) => (i.style.display = "none"))
            }, 1000)
          }
        })
      })
    </script>
  </body>
</html>

Check out this pin by Jesse Pence (@jesse-pence) on CodePen.

As you can see, CSS Animations give us a lot more control over the animation. Unfortunately, we lose the automatic positioning and interpolation that we had with the transition. So, if we want to smoothly transition between the two states, we have to manually animate the positioning. If this was an MPA and the larger image had it’s own URL, it would be almost impossible to make this seamless.

Animating Between Pages

So, we’ve seen how we can animate between states on the same page. This covers single page applications, but what about multi-page applications? How can we animate between pages? Well, the short answer is that, without the View Transitions API, we just can’t.

At least, not without a lot of JavaScript. In the past, the most common trick was to simply intercept each link click. Then, you could begin your animations as you load the page in the background. Even if you didn’t do client-side routing, you could still start and end each page with an animation. Here’s a simple example where we fade in and out of each page.

Fade Demo

<!DOCTYPE html>
<html>
  <head>
    <style>
      body {
        margin: 0;
        opacity: 0;
        transition: opacity 1s;
        background: black;
      }
      main {
        display: flex;
        background: #990033;
        height: 100vh;
      }
      main a {
        color: #fff;
        background: #339909;
        height: fit-content;
        padding: 1em;
        margin: 1em;
        border-radius: 2em;
      }
      main span {
        align-self: end;
        color: #fff;
        background: #330099;
        height: fit-content;
        border-radius: 4em;
      }
    </style>
    <title>Old-School Full Page Transitions</title>
  </head>
  <body>
    <main>
      <a
        onclick="transitionToPage(this.href)"
        href="/demos/into-the-future/5-view-transitions-api/11/other"
        >Other</a
      >
      <span>HI</span>
    </main>
    <script>
      // taken from:
      // https://stackoverflow.com/questions/47391462/how-to-do-transition-effects-between-two-html-pages

      window.transitionToPage = function (href) {
        event.preventDefault()
        document.querySelector("body").style.opacity = 0
        setTimeout(function () {
          window.location.href = href
        }, 1000)
      }

      document.addEventListener("DOMContentLoaded", function (event) {
        setTimeout(function () {
          document.querySelector("body").style.opacity = 1
        })
      })
    </script>
  </body>
</html>
<!DOCTYPE html>
<html>
  <head>
    <style>
      body {
        margin: 0;
        opacity: 0;
        transition: opacity 2s;
        background: black;
      }
      main {
        display: flex;
        justify-content: flex-end;
        align-items: end;
        background: #339909;
        height: 100vh;
      }
      main a {
        color: #00f;
        background: #990033;
        height: fit-content;
        padding: 1em;
        margin: 1em;
        font-size: 2em;
        border-radius: 0.5em;
      }
      main span {
        color: #fff;
        align-self: start;
        background: #f0f;
        height: fit-content;
        padding: 2em;
        margin: 2em;
        font-size: 3em;
        border-radius: 2em;
      }
    </style>
  </head>
  <body>
    <main>
      <a
        href="/demos/into-the-future/5-view-transitions-api/11/"
        onclick="transitionToPage(this.href)"
        >Home</a
      >
      <span>HI</span>
    </main>
    <script>
      window.transitionToPage = function (href) {
        event.preventDefault()
        document.querySelector("body").style.opacity = 0
        setTimeout(function () {
          window.location.href = href
        }, 1000)
      }

      document.addEventListener("DOMContentLoaded", function (event) {
        setTimeout(function () {
          document.querySelector("body").style.opacity = 1
        })
      })
    </script>
  </body>
</html>

This gives a nice effect, but there’s not much continuity between pages. If we have something like a nav bar, do we really want that to fade in and out of each page? You could use techniques like FLIP(First, Last, Invert, Play) to make the animation more seamless.

But, this takes a lot of coordination, and it’s really hard to get right. Here’s a demo where we store a reference to an element in localStorage so we can give the illusion that it is persisting between pages. As you can see, as long as the network isn’t too slow, it works pretty well although the positioning isn’t perfect.

FLIP demo

<!DOCTYPE html>
<html>
  <head>
    <title>Page 1</title>
    <style>
      #myElement {
        position: absolute;
        top: 50px;
        left: 50px;
        width: 100px;
        height: 100px;
        background: red;
      }
    </style>
  </head>
  <body>
    <div id="myElement"></div>

    <script>
      let el = document.querySelector("#myElement")
      let firstRect = el.getBoundingClientRect()

      let lastRect = JSON.parse(localStorage.getItem("flipAnimation"))

      let deltaX = lastRect.left - firstRect.left
      let deltaY = lastRect.top - firstRect.top
      let scaleX = lastRect.width / firstRect.width
      let scaleY = lastRect.height / firstRect.height

      el.animate(
        [
          {
            transform: `translate(${deltaX}px, ${deltaY}px) scale(${scaleX}, ${scaleY})`,
          },
          { transform: "none" },
        ],
        {
          duration: 1000,
          easing: "ease-in-out",
        }
      )

      el.addEventListener("click", function () {
        // Save the initial state to localStorage
        let firstRect = el.getBoundingClientRect()
        localStorage.setItem(
          "flipAnimation",
          JSON.stringify({
            top: firstRect.top,
            left: firstRect.left,
            width: firstRect.width,
            height: firstRect.height,
          })
        )

        // Navigate to the second page
        location.href = "page2.html"
      })
    </script>
  </body>
</html>
<!DOCTYPE html>
<html>
  <head>
    <title>Page 2</title>
    <style>
      #myElement {
        position: absolute;
        top: 150px;
        left: 150px;
        width: 200px;
        height: 200px;
        background: blue;
      }
    </style>
  </head>
  <body>
    <div id="myElement"></div>

    <script>
      let el = document.querySelector("#myElement")
      let lastRect = el.getBoundingClientRect()

      let firstRect = JSON.parse(localStorage.getItem("flipAnimation"))

      let deltaX = firstRect.left - lastRect.left
      let deltaY = firstRect.top - lastRect.top
      let scaleX = firstRect.width / lastRect.width
      let scaleY = firstRect.height / lastRect.height

      el.animate(
        [
          {
            transform: `translate(${deltaX}px, ${deltaY}px) scale(${scaleX}, ${scaleY})`,
          },
          { transform: "none" },
        ],
        {
          duration: 1000,
          easing: "ease-in-out",
        }
      )

      el.addEventListener("click", function () {
        // Save the final state to localStorage
        let lastRect = el.getBoundingClientRect()
        localStorage.setItem(
          "flipAnimation",
          JSON.stringify({
            top: lastRect.top,
            left: lastRect.left,
            width: lastRect.width,
            height: lastRect.height,
          })
        )

        // Navigate back to the first page
        location.href = "page1.html"
      })
    </script>
  </body>
</html>

Wouldn’t it be nice if the browser could do this for us? Well, as long as you’re using a Chromium browser, it can! Let’s take a look at the View Transitions API. Let’s start with a simple SPA demo.

How View Transitions Work

view-transition-one
<!DOCTYPE html>
<html lang="en">
  <head>
    <style>
      .main-content {
        view-transition-name: main-content;
      }
      

      ::view-transition-new(main-content) {
        animation-name: slide-content;
      }

      @keyframes slide-content {
        from {
          transform: translateY(100%);
        }
      }

      /* html {
  view-transition-name: none;
      } */

      /* footer {
  view-transition-name: footer;
      } */
    </style>
  </head>
  <body>
    <header>Header</header>
    <button id="btn">Click me</button>
    <main class="main-content">Main Content</main>
    <footer>Footer</footer>
    <script>
      const button = document.querySelector("#btn")
      const content = document.querySelector(".main-content")

      const view1 = `
      <div class="view1">
      <h1>View 1</h1>
      <p>This is some stuff.</p>
      </div>
    `
      const view2 = `
      <div class="view2">
      <h1>View 2</h1>
      <p>Totally different stuff.</p>
      </div>
    `

      button.addEventListener("click", () => {
        document.startViewTransition(() => {
          content.innerHTML = content.innerHTML === view1 ? view2 : view1
        })
      })
    </script>
  </body>
</html>

Check out this pin by Jesse Pence (@jesse-pence) on CodePen.

When the button is clicked, we call the document.startViewTransition() method. The API starts by taking a screenshot of the current state of the page. Next, it invokes whatever callback function we give it. We’re just completely blowing away the main content of the page here.

After this functions runs and the DOM begins updating, the API captures the new state of the page as a live representation. This doesn’t have to be a still image— it can even be a video. The browser uses this live view of the new DOM and the screenshot of the old one to construct a nested tree of pseudo-elements. Finally, it places all of these pseudo-elements on top of the entire affected area as it transitions from the old state to the new state.

The Pseudo Element Tree

The most important of these pseudo-elements are ::view-transition-old and ::view-transition-new which represent the old and new page views respectively. These are held within a ::view-transition-image-pair which is responsible for isolating these views from the rest of the page so that their images can correctly cross-fade. Zooming out even further, each of these “image pairs” is itself nested within a ::view-transition-group that manages animating the size and position difference of the two states. Every ::view-transition-group is a sibling of one another, but there are plans to enhance the nesting structure in the future.

When a transition is initiated, the entire page is covered by a ::view-transition parent pseudo-element which contains a single ::view-transition-group(root) pseudo-element. If you don’t add CSS rules to any of these pseudo-elements, the default transition will be a simple opacity cross-fade between the old and new views over 250ms. If you want to customize the transition, you can some CSS to one of the pseudo-elements— usually ::view-transition-old or ::view-transition-new. You can also create additional ::view-transition-group pseudo-elements to control the animation of specific elements.

If you know an element will persist between states, you can give it a unique view-transition-name. This is enough for the API to automatically interpolate the position and size of the element between the old and new views. This keeps it from fading out and back in— although the animation can be a little jarring without tweaking it. In the example above, you can uncomment the CSS rule for the footer element to watch it slide to make room for the new content on each transition.

Alternatively, you can opt any element out of the transition entirely by giving it a view-transition-name property with a value of “none”. This is the only name which can be shared. Generally, if you want to use the API for fine-grained updates, it seems like a good idea to put this on the root element.

Notice how we can’t click the button in the example above while the content is transitioning? That’s because the button is covered by the parent ::view-transition pseudo-element. We need to give that pseudo-element a pointer-events: none rule to make it clickable. After doing this and removing the root element from the animation, you’ll be able to click the button to your heart’s content.

Graceful Degredation & The transitionHelper Function

However, we are barely scratching the surface of the API. And, like I said, it’s only in Chrome right now. What about all of our Firefox and Safari users? The site just doesn’t work for them! Or, worse yet, what if the user doesn’t want to see any animations? We need to be able to gracefully degrade (my life motto in my twenties). Let’s look at a more robust example.

view-transition-two
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <style>
      html {
        view-transition-name: none;
      }

      .main-content {
        height: 200px;
        margin: 20px;
        display: flex;
        flex-direction: column;
        justify-content: center;
        align-items: center;
        background-color: #eee;
      }
      .button {
        background-color: #333;
        color: #fff;
        padding: 10px;
        border: none;
        border-radius: 5px;
        cursor: pointer;
      }
      .content {
        margin-top: 10px;
        padding: 10px;
        border: 1px solid #333;
        border-radius: 5px;
        background-color: #fff;
        view-transition-name: scoot;
      }

      ::view-transition-old(scoot),
      ::view-transition-new(scoot) {
        mix-blend-mode: normal;
        overflow: clip;
      }

      ::view-transition-group(scoot) {
        background-color: #333;
      }

      ::view-transition-image-pair(scoot) {
        border: 5px solid #1af;
      }

      ::view-transition-old(scoot) {
        animation: scoot ease-out 1s reverse;
      }
      ::view-transition-new(scoot) {
        animation: scoot ease-in 1s forwards;
      }

      @keyframes scoot {
        from {
          transform: translateX(100%);
        }
      }

      .scabbadoobie {
        animation: scabbadoobie 1s ease-in-out infinite;
      }

      @keyframes scabbadoobie {
        0% {
          background-color: #333;
        }
        33% {
          background-color: #1af;
        }
        66% {
          background-color: #f1a;
        }
        100% {
          background-color: #333;
        }
      }
    </style>
  </head>
  <body>
    <div class="main-content">
      <button class="button" id="button">Click me</button>
      <main class="content"></main>
    </div>
    <script>
      const button = document.querySelector(".button")
      const content = document.querySelector(".content")

      const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)")
      const skipTransition = mediaQuery.matches

      const view1 = `
      <div class="view1">
      <h1>View 1</h1>
      <p>This is some stuff.</p>
      </div>
      `
      const view2 = `
      <div class="view2">
      <h1>View 2</h1>
      <p>Totally different stuff.</p>
      </div>
      `

      button.addEventListener("click", () => {
        const transition = transitionHelper({
          classNames: ["scabbadoobie"],
          updateDOM: () => {
            content.innerHTML = content.innerHTML === view1 ? view2 : view1
          },
          skipTransition,
        })

        handleAnimation(transition, button)
      })

      function transitionHelper({
        skipTransition = false,
        classNames = [],
        updateDOM,
      }) {
        if (skipTransition || !document.startViewTransition) {
          const updateCallbackDone = Promise.resolve(updateDOM())

          return {
            ready: Promise.reject(Error("View transitions unsupported")),
            updateCallbackDone,
            finished: updateCallbackDone,
          }
        }
        const transition = document.startViewTransition(updateDOM)

        transition.ready.finally(() => {
          document.documentElement.classList.add(...classNames)
          console.log(
            "You can do things as soon as the transition is ready (all the data is loaded and ready to go)"
          )
        })

        transition.finished.finally(() => {
          document.documentElement.classList.remove(...classNames)
          console.log("And clean-up after the transition is finished")
        })

        return transition
      }

      function handleAnimation(transition, element) {
        if (skipTransition) {
          element.style.backgroundColor = "green"
          return
        }
        transition.ready.finally(() => {
          element.animate(
            [
              { backgroundColor: "red", transform: "scale(1)" },
              { backgroundColor: "green", transform: "scale(1.5)" },
              { backgroundColor: "blue", transform: "scale(0.5)" },
              { backgroundColor: "green", transform: "scale(1)" },
            ],
            { duration: 1000 }
          )
        })

        transition.finished.finally(() => {
          element.style.backgroundColor = "green"
        })
      }
    </script>
  </body>
</html>

Check out this pin by Jesse Pence (@jesse-pence) on CodePen.

Here, we are using the transitionHelper function provided by Jake Archibald in his fantastic introductory article. Basically, it just provides a fallback for browsers that don’t support view transitions. It also takes in an array of classNames that can be added when the transition is ready and/or removed when the transition is finished. This is useful for specifying which elements should be animated with each transition.

The viewTransition interface only has three properties and one method. Each of the properties is a promise. The first is ready which resolves when all the data is loaded and ready to go. The second is updateCallbackDone which resolves when whatever function we’re using to update the DOM is done. The third is finished which resolves… well, when the transition is finished. The only method is skipTransition() which skips the animation, but still changes the DOM. Here, we’re using it to check if our users have set a preference for reduced motion although the docs recommend just setting a media query in the CSS for this.

Additionally, I added some selectors here to the ::view-transition-group and ::view-transition-image-pair pseudo-elements just to show where they are. The ::view-transition-group is best used for controlling the animation of persistent objects with specific view-transition-name properties, and the ::view-transition-image-pair is useful if you have color blending issues. Note here that our keyframes only required a single from value. I have found that you often only need to define one direction for the animation as the API automatically positions the element for us.

This is also our first and only use of the incredibly powerful Web Animations API. It’s fantastic. I just don’t know how to use it very well. Let’s go back and apply some of our new-found skills to the Kitten Gallery from earlier.

kitten-gallery-three
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Animated Image Gallery</title>
    <style>
      body {
        margin: 0;
        overflow: hidden;
        background: #000;
      }

      #gallery {
        display: flex;
        justify-content: center;
        align-items: center;
        height: 100vh;
      }

      .gallery-item {
        width: 200px;
        height: 200px;
        cursor: pointer;
      }

      .gallery-item img {
        width: 100%;
        height: 100%;
        object-fit: contain;
      }

      .gallery-item.active {
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        height: max-content;
        width: max-content;
      }

      .gallery-item.active img {
        width: auto;
        height: auto;
        object-fit: cover;
        max-width: 100%;
        max-height: 100%;
      }
      /* ::view-transition-group(active) {
        animation-duration: 0.75s;
      }

      ::view-transition-old(active),
      ::view-transition-new(active) {
        animation: none;
      }

      ::view-transition-old(active) {
        opacity: 0;
      } */
    </style>
  </head>
  <body>
    <div id="gallery"></div>
    <script>
      const gallery = document.querySelector("#gallery")
      const root = document.documentElement

      const images = [
        "https://placekitten.com/200/200",
        "https://placekitten.com/300/300",
        "https://placekitten.com/400/400",
        "https://placekitten.com/500/500",
        "https://placekitten.com/600/600",
        "https://placekitten.com/700/700",
        "https://placekitten.com/800/800",
        "https://placekitten.com/900/900",
      ]

      images.forEach((src) => {
        const item = document.createElement("div")
        item.classList.add("gallery-item")
        const img = document.createElement("img")
        img.src = src
        img.alt = "cute kitten"
        item.appendChild(img)
        gallery.appendChild(item)
      })

      document.querySelectorAll(".gallery-item").forEach((item) => {
        item.addEventListener("click", () => {
          item.style.viewTransitionName = "active"
          if (!item.classList.contains("active")) {
            transitionHelper({
              updateDOM: () => {
                item.classList.add("active")
                document
                  .querySelectorAll(".gallery-item:not(.active)")
                  .forEach((item) => {
                    item.style.display = "none"
                  })
              },
            })
          } else {
            transitionHelper({
              updateDOM: () => {
                item.classList.remove("active")
                document
                  .querySelectorAll(".gallery-item:not(.active)")
                  .forEach((item) => {
                    item.style.display = "block"
                  })
              },
            })
          }
        })
      })

      function transitionHelper({ updateDOM }) {
        if (!document.startViewTransition) {
          const updateCallbackDone = Promise.resolve(updateDOM())

          return {
            ready: Promise.reject(Error("View transitions unsupported")),
            updateCallbackDone,
            finished: updateCallbackDone,
          }
        }

        const transition = document.startViewTransition(updateDOM)

        transition.finished.finally(() => {
          document.querySelectorAll(".gallery-item").forEach((item) => {
            item.style.viewTransitionName = "none"
          })
        })

        return transition
      }
    </script>
  </body>
</html>

Check out this pin by Jesse Pence (@jesse-pence) on CodePen.

There is not a single bit of traditional CSS animation here! Remember, the idea is that we don’t know how many images will be in our gallery ahead of time. Because of this, we cannot give each element a unique view-transition-name in the stylesheet. Unfortunately, the biggest rule of the View Transitions API is that no two items can have the same view-transition-name. So, we have to get creative.

My original method was to loop through and give each item a unique name in the JavaScript. This actually worked even better at creating a smooth transition without configuration. There is a big issue with this, however. Because the ::view-transition animation properties are pseudo-elements, we can’t easily adjust them with JS the way we did with the elements in the animation sandbox.

Pseudo-elements are not a part of the DOM, so they are not accessible to JavaScript. So, while we can give an item a name, we can’t change the animation properties of that name. I found a crazy IIFE on StackOverflow that allows you to append a new style tag to the head of the document. This was pretty hacky, and it only seemed to partially work. So, I decided to try a different approach.

Instead, I use click listener to apply a special viewTransitionName right before the transition begins. Then, I leverage the finished promise to remove the name when the transition is complete. This seems to be the idiomatic way of doing things.

When I built the demo, the viewTransitionName was not a part of the CSSStyleDeclaration TypeScript definitions, but I submitted a pull request and it was merged. So, you shouldn’t have any issues using it. Don’t mind me while I pat myself on the back for my first DT contribution.

I digress. As you can see, this animation is not very smooth. While it is nice that we get it for free, we can do better. If you want to stick with the standard position and shape interpolation, you can simply make a few tweaks to the pseudo-elements. You can uncomment the rules in the CSS to see one way of making this more seamless. But, we can do even better than that. Let’s see how we can use custom CSS animations to personalize the transition.

kitten-gallery-four
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Animated Image Gallery</title>
    <style>
      body {
        margin: 0;
        overflow: hidden;
        background: #000;
        view-transition-name: none;
      }

      #gallery {
        display: flex;
        justify-content: center;
        align-items: center;
        height: 100vh;
        view-transition-name: gallery;
      }

      .gallery-item {
        width: 200px;
        height: 200px;
        cursor: pointer;
      }

      .gallery-item img {
        width: 100%;
        height: 100%;
        object-fit: contain;
      }

      .gallery-item.active {
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        height: max-content;
        width: max-content;
      }

      .gallery-item.active img {
        width: auto;
        height: auto;
        object-fit: cover;
        max-width: 100%;
        max-height: 96%;
        view-transition-name: active;
      }

      ::view-transition-old(gallery) {
        animation: moveRight 0.75s ease-in-out;
      }

      ::view-transition-new(gallery) {
        animation: fadeIn 0.75s ease-in-out;
      }

      ::view-transition-old(active) {
        animation: moveRight 0.75s ease-in-out;
      }

      ::view-transition-new(active) {
        animation: activate 0.75s ease-in-out;
      }

      @keyframes activate {
        0% {
          transform: scale(0.5);
          filter: blur(10px);
        }
        50% {
          transform: scale(1.2);
        }
        100% {
          transform: scale(1);
          filter: blur(0);
        }
      }

      @keyframes moveRight {
        to {
          transform: translateX(100%);
          opacity: 0;
        }
      }

      @keyframes fadeIn {
        from {
          opacity: 0;
        }
      }
    </style>
  </head>
  <body>
    <div id="gallery"></div>
    <script>
      const gallery = document.querySelector("#gallery")

      const images = [
        "https://placekitten.com/200/200",
        "https://placekitten.com/300/300",
        "https://placekitten.com/400/400",
        "https://placekitten.com/500/500",
        "https://placekitten.com/600/600",
        "https://placekitten.com/700/700",
        "https://placekitten.com/800/800",
        "https://placekitten.com/900/900",
      ]

      images.forEach((src) => {
        const item = document.createElement("div")
        item.classList.add("gallery-item")
        const img = document.createElement("img")
        img.src = src
        img.alt = "cute kitten"
        item.appendChild(img)
        gallery.appendChild(item)
      })

      const galleryItems = document.querySelectorAll(".gallery-item")

      galleryItems.forEach((item, index) => {
        item.addEventListener("click", () => {
          transitionHelper({
            updateDOM: () => {
              if (!item.classList.contains("active")) {
                item.classList.add("active")
                galleryItems.forEach((item) => {
                  if (!item.classList.contains("active")) {
                    item.style.display = "none"
                  }
                })
              } else {
                item.classList.remove("active")
                galleryItems.forEach((item) => {
                  item.style.display = "block"
                })
              }
            },
          })
        })
      })

      function transitionHelper({ updateDOM }) {
        if (!document.startViewTransition) {
          const updateCallbackDone = Promise.resolve(updateDOM())

          return {
            ready: Promise.reject(Error("View transitions unsupported")),
            updateCallbackDone,
            finished: updateCallbackDone,
          }
        }

        const transition = document.startViewTransition(updateDOM)

        return transition
      }
    </script>
  </body>
</html>

Check out this pin by Jesse Pence (@jesse-pence) on CodePen.

As you can see, you can use separate animations for the view-transition-old and view-transition-new selectors to make the transition more interesting. So, is this that much better than just using a normal animation on the image? Well, yes and no. For a simple photo gallery like this, it may seem unnecessary.

But, within the context of a full application, it actually simplifies things quite a bit. By using the same view-transition-name properties between pages, the API takes care of everything in between. Rather than imperatively animating each element, we can simply tell the browser what the end result should be. Let’s take a look at some ways of using this in our simple e-commerce app.

Implementing the API in Our App

Like the last chapter on the Navigation API, the View Transitions API is extremely easy to use. And, what’s more— they go hand in hand. As you can see in our app, changing out the DOM on the client-side with the navigation API can be jarring without some sort of transition.

I created a version of the app that tied the view transition to the router which made sense to me initially. But, I realized that this was a mistake. While this worked well for the most part, it required every transition to be a part of the routing process.

This made it hard to do things like smoothly transition features that I added later like a multi-step checkout or a loading spinner— things that don’t have a URL. I added a timeout to the db function in the broken version, and the loading spinner doesn’t pop up anymore no matter how long you set it. Instead, it makes more sense to initiate the transition in the render function itself because that is where we are actually changing the DOM.

Coming into this chapter, our render function is comically simple. Currently, when we route to a new page, we unceremoniously switch out the innerHTML for the new view template. If our app was more complex, this could lead to performance issues, and this is not the best for user experience. In addition, our rendering process is currently completely unrelated to our hydration process.

In each component, we simply call the render function to change the innerHTML of the main element to a new string template literal. But, this doesn’t add any event listeners, so each interactive component contains logic to add them after running the render function. While this has worked for us so far, the View Transitions API adds new complexity to the situation.

As I mentioned, when it begins a transition, it covers the DOM with pseudo-elements containing screenshots of the old and new states. This makes the DOM underneath inaccessible to JavaScript. If this were not the case, our new render function could be this simple.

export default function render(component) {
    document.startViewTransition(main.innerHTML = component)
}

But, this breaks all of our event listeners. And, as I mentioned previously, the View Transition API has not yet been implemented in Firefox or Safari. So, this means that our app is currently partially broken in Chrome and completely broken in other browsers. We need to make sure that we add our event listeners before the transition begins. And, we need to use our transitionHelper function from Jake Archibald to make sure that our app works in all browsers. With these improvements, our render function looks like this.

export default function render({ component, callback }) {
  transitionHelper({
    updateDOM: () => {
      main.innerHTML = component
      callback ? callback() : null
    },
  })
}

So, I added an optional callback to the render function. This allows us to add event listeners or make any dynamic changes to the DOM before the transition begins. I like using an object for my parameters because you don’t have to worry about the order of the arguments, and it will give us better type safety when we add TypeScript in the next chapter. Unfortunately, this means that we have to update all of our render calls. Let’s take a look at the product page as an example.

import ProductComponent from "../components/Product.js"
import render, { buttonFinderAdd } from "../components/render.js"
import { getProducts } from "../components/store.js"

export default async function ProductsPage() {
  document.title = "Products"
  const products = await getProducts()
  const productsHTML = Object.values(products)
    .map((product) => ProductComponent(product))
    .join("")
  render({
    component: `
    <h1>Products</h1>
    <div class="products">${productsHTML}</div>
  `,
    callback: buttonFinderAdd,
  })
}

Same general concept, but I do appreciate how much cleaner this is. Believe me— Without the named arguments, this is an ugly mess. Anyways… enough of my neuroses! Let’s get back to the View Transitions API.

If we were fine with the default transition, we could stop there. But, why do that when customizing the animations is this easy? You have to relish the simplicity of these new API’s.

/* There are more rules than I show here, 
I'm just showing the ones that affect the transitions.
You can see the full CSS in the demo. */
nav,
footer {
  view-transition-name: none;
}

h1 {
  view-transition-name: h1;
}

main {
  view-transition-name: main;
}

::view-transition-group(activeImage),
::view-transition-group(h1) {
  animation-duration: 0.75s;
}

::view-transition-old(activeImage),
::view-transition-old(h1) {
  opacity: 0;
}

::view-transition-new(activeImage) {
  opacity: 1;
  animation: scaleUp 0.75s ease-in-out;
}

::view-transition-new(h1) {
  opacity: 1;
  animation: none;
}

::view-transition-old(main) {
  animation: slide-left 0.75s ease-in-out;
}

::view-transition-new(main) {
  animation: slide-right 0.75s ease-in-out;
}

@keyframes slide-left {
  to {
    transform: translateX(-100%);
    opacity: 0;
  }
}

@keyframes slide-right {
  from {
    transform: translateX(100%);
  }
}

With that, the vast majority of our work is complete. One note: The h1 view-transition-name only works because there should always be only one h1 on the screen at a time. This rule of semantic HTML coincides with the View Transition API’s one big rule of “only one element per viewTransitionName”. This works for the header, but we’ll need to think of something else for our product images.

Since there is more than one image on the products page, the entire transition will break if we give them all the same name. However, we do need a way to tell our app when a user has clicked on a product so that we can get a cool animation there. So, we’re going to have to be a little creative with our implementation.

Jake Archibald did a fantastic demo of how to do image continuity.. I swear I made my cat gallery before I saw this. We’re going to be doing something similar for our images, but a little less gracefully.

In addition, we’re going to do a little house-keeping while we’re changing things up. Here’s what our render function looks like now. I’ll also show the activeImage helper functions that we’re using.

export default function render({ component, callback }) {
  addOldActiveClass()
  transitionHelper({
    updateDOM: () => {
      !component ? Nope("404") : null
      main.innerHTML = component
      main.scrollTop = 0
      addNewActiveClass()
      callback ? callback() : null
    },
  })
}

let clickedImage = false
let oldImage = null

document.addEventListener("click", (e) => {
  if (e.target.tagName === "IMG" || e.target.parentNode.tagName === "A") {
    clickedImage = true
    if (e.target.tagName === "IMG") {
      oldImage = e.target
    } else {
      oldImage = e.target.parentNode.querySelector("img")
    }
  } else {
    clickedImage = false
  }
})

function addOldActiveClass() {
  if (!clickedImage) return
  oldImage.style.viewTransitionName = "activeImage"
}

function addNewActiveClass() {
  if (!clickedImage) return
  const newImage = document.querySelector("img")
  newImage.style.viewTransitionName = "activeImage"
  newImage.classList.add("activeImage")
}

We check on every single click if the user is clicking on an image or something inside of an anchor tag that contains an image (because each product’s image and title are wrapped in a link). If either of these conditions are true, we set a flag, store the image that was clicked, and then add a viewTransitionName to the image. Next, we apply the same viewTransitionName to the first image we find as we update the DOM as well as a class that increases the size of the image.

This whole process only works if you are sure that the first image on the next page is the same image that was clicked on the previous page. This is rarely the case on most webpages, but it’s fine for our simple app. If we wanted, we could add an activeID to the new image as we render the new page, and then use that to find the correct image. But, we don’t need to worry about that. Instead, let’s liven up our checkout form a bit.

The Form Example

Something like a multi-step form seems ripe for this API. Often, you will see the steps of a form get split up to keep the user from being overwhelmed and to allow things like validation to occur at a more granular level. To me, this seems like a perfect use case for view transitions. First, let’s see how we could implement this with some simple vanilla CSS.

vanilla-form
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Multi-step Form with Animations</title>
    <style>
      .form-container {
        width: 400px;
        margin: 50px auto;
        background: #fff;
        padding: 30px;
        border-radius: 5px;
        box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
      }

      .form-step {
        transition: all 0.3s ease-in-out;
      }

      .form-step.hidden {
        opacity: 0;
        transform: translateX(100px);
      }

      .form-step.slide-left {
        opacity: 0;
        transform: translateX(-100px);
      }

      .form-step.slide-right {
        opacity: 0;
        transform: translateX(100px);
      }

      input {
        display: block;
        width: 100%;
        padding: 10px;
        margin-bottom: 10px;
        border: 1px solid #ddd;
        border-radius: 5px;
      }

      button {
        padding: 10px 20px;
        border: none;
        border-radius: 5px;
        background: #333;
        color: #fff;
        cursor: pointer;
      }

      button:hover {
        background: #444;
      }

      #submit {
        display: none;
      }
    </style>
  </head>
  <body>
    <div class="form-container">
      <div id="step1" class="form-step">
        <input type="text" placeholder="Name" />
        <input type="email" placeholder="Email" />
      </div>
      
      <div id="step2" class="form-step hidden">
        <input type="text" placeholder="Address" />
        <input type="text" placeholder="City" />
      </div>
      
      <div id="step3" class="form-step hidden">
        <input type="text" placeholder="Credit Card Number" />
        <input type="text" placeholder="CVV" />
      </div>

        <button id="prev">Previous</button>
        <button id="next">Next</button>
        <button id="submit">Submit</button>
      </div>
    </div>

    <script>
      const steps = document.querySelectorAll('.form-step');
      const prevBtn = document.querySelector('#prev');
      const nextBtn = document.querySelector('#next');
      const submitBtn = document.querySelector('#submit');
      let currentStep = 0;

      function showStep(n) {
        steps.forEach((step, i) => {
          if (i === n) {
            step.classList.remove('hidden');
            step.classList.remove('slide-left');
            step.classList.remove('slide-right');
          } else if (i < n) {
            step.classList.add('slide-left');
            step.classList.add('hidden');
          } else {
            step.classList.add('slide-right');
            step.classList.add('hidden');
          }
        });
      }

      function updateButtons(n) {
        if (n === 0) {
          prevBtn.disabled = true;
        } else if (n === steps.length - 1) {
          nextBtn.disabled = true;
          nextBtn.style.display = 'none';
          submitBtn.disabled = false;
          submitBtn.style.display = 'inline-block';
        } else {
          prevBtn.disabled = false;
          nextBtn.disabled = false;
          submitBtn.disabled = true;
          nextBtn.style.display = 'inline-block';
          submitBtn.style.display = 'none';
        }
      }

      showStep(currentStep);
      updateButtons(currentStep);

      prevBtn.addEventListener('click', () => {
        if (currentStep === 0) return;
        currentStep--;
        showStep(currentStep);
        updateButtons(currentStep);
      });

      nextBtn.addEventListener('click', () => {
        currentStep++;
        showStep(currentStep);
        updateButtons(currentStep);
      });

      submitBtn.addEventListener('click', () => {
        alert('Form Submitted!');
      });
    </script>
  </body>
</html>

Check out this pin by Jesse Pence (@jesse-pence) on CodePen.

Not bad. We display and hide steps as the user progresses through the form. We vary the animation based on the direction of the transition. Nothing too fancy, but it gets the job done. Let’s see how we can implement this with the View Transitions API while adding a few more bells and whistles.

new-form

new-form-demo.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Advanced Form with View Transitions API</title>
    <style>
      :root {
        transition: background-color 0.3s;
        --progress-left: 0;
      }

      .form-container {
        width: 400px;
        z-index: -1;
        background: #fff;
        margin: 50px auto;
        padding: 30px;
        border-radius: 5px;
        box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
      }

      input {
        display: block;
        width: 100%;
        padding: 10px;
        margin-bottom: 10px;
        border: 2px solid #ddd;
        border-radius: 5px;
      }

      input.error {
        border-color: red;
      }

      .error-message {
        display: none;
        background: red;
        color: #fff;
        margin-bottom: 10px;
        view-transition-name: error-message;
      }

      .error-message.active {
        display: block;
        position: relative;
        scale: 1;
        transition: all 0.6s ease-in-out;
      }

      button {
        padding: 10px 20px;
        border: none;
        border-radius: 5px;
        background: #333;
        color: #fff;
        cursor: pointer;
      }

      button:hover {
        background: #444;
      }

      .form-step {
        display: none;
        view-transition-name: slide-left;
      }

      .form-step.active {
        display: block;
      }

      #next {
        view-transition-name: next;
        transition: opacity 0.2s;
      }

      #prev {
        display: none;
        view-transition-name: prev;
        transition: opacity 0.2s;
      }

      #submit {
        display: none;
        view-transition-name: submit;
        transition: opacity 0.2s;
      }

      .progress-bar {
        height: 10px;
        background-color: #333;
        border-radius: 5px;
        width: 0;
        view-transition-name: progress-bar;
      }

      .progress-number {
        left: calc(var(--progress-left) - 20px);
        border-radius: 50%;
        background-color: #333;
        width: fit-content;
        view-transition-name: progress-number;
        position: relative;
        top: -20px;
        padding: 5px;
        color: #fff;
        font-size: 1.5rem;
      }

      .error-transition {
        background-color: red;
      }

      .success-transition {
        background-color: green;
      }

      .back-transition {
        background-color: #333;
      }

      ::view-transition-old(progress-bar),
      ::view-transition-new(progress-bar) {
        animation-name: progress-bar-transition;
        mix-blend-mode: normal;
        height: 100%;
      }

      ::view-transition-old(progress-number) {
        opacity: 0;
      }

      ::view-transition-new(progress-number) {
        animation-name: progress-number-transition;
        height: 100%;
        mix-blend-mode: normal;
      }

      ::view-transition-old(slide-left) {
        animation-name: slide-left;
        border: 5px solid green;
      }

      ::view-transition-old(slide-right) {
        animation-name: slide-right;
        border: 5px solid #333;
      }

      ::view-transition-new(error-message) {
        animation-name: error-transition;
        background-color: red;
      }

      ::view-transition-group(error-message) {
        animation-duration: 1s;
      }

      ::view-transition-group(root) {
        animation-duration: 0.5s;
      }

      ::view-transition-group(prev),
      ::view-transition-group(next),
      ::view-transition-group(submit) {
        opacity: 50%;
      }

      @keyframes progress-bar-transition {
        from {
          opacity: 1;
        }
      }

      @keyframes slide-left {
        to {
          opacity: 0;
          transform: translateX(-100px);
        }
      }

      @keyframes slide-right {
        to {
          opacity: 0;
          transform: translateX(100px);
        }
      }

      @keyframes error-transition {
        from {
          opacity: 0;
          transform: translateY(-10px);
        }
      }
    </style>
  </head>
  <body>
    <div class="form-container">
      <span class="progress-number">0%</span>
      <div class="progress-bar" id="progress-bar"></div>
      <div id="step1" class="form-step active">
        <input type="text" id="name" placeholder="Name" />
        <input type="email" id="email" placeholder="Email" />
      </div>

      <div id="step2" class="form-step">
        <input type="text" id="address" placeholder="Address" />
        <input type="text" id="city" placeholder="City" />
      </div>

      <div id="step3" class="form-step">
        <input type="text" id="credit-card" placeholder="Credit Card Number" />
        <input type="text" id="cvv" placeholder="CVV" />
      </div>

      <div id="step4" class="form-step">
        <h2>Success!</h2>
      </div>

      <p class="error-message" id="error-message"></p>

      <div id="buttons">
        <button id="prev">Previous</button>
        <button id="next">Next</button>
        <button id="submit">Submit</button>
      </div>
    </div>

    <script>
      const steps = document.querySelectorAll(".form-step")
      const prevButton = document.getElementById("prev")
      const nextButton = document.getElementById("next")
      const submitButton = document.getElementById("submit")
      const progressBar = document.getElementById("progress-bar")
      const progressNumber = document.querySelector(".progress-number")
      const errorMessage = document.getElementById("error-message")

      let currentStep = 0

      prevButton.addEventListener("click", prevHandler)
      nextButton.addEventListener("click", nextHandler)
      submitButton.addEventListener("click", submitHandler)

      function prevHandler() {
        steps.forEach((step) => {
          step.style.setProperty("view-transition-name", "slide-right")
        })
        if (currentStep === steps.length - 1) {
          alert("DON'T YOU DARE GO BACK!")
          return
        }
        const transition = transitionHelper({
          classNames: ["back-transition"],
          updateDOM: () => updateDOM(currentStep - 1),
        })
      }

      function nextHandler() {
        steps.forEach((step) => {
          step.style.setProperty("view-transition-name", "slide-left")
        })
        if (validateStep(currentStep)) {
          const transition = transitionHelper({
            classNames: ["success-transition"],
            updateDOM: () => updateDOM(currentStep + 1),
          })
        } else {
          showError("Please fill out all fields")
        }
      }

      function submitHandler() {
        alert("Form submitted!")
        document.documentElement.style.backgroundColor = "blue"
      }

      function animateNumber(start, end, duration, callback) {
        const range = end - start
        let current = start
        const increment = end > start ? 1 : -1
        const stepTime = Math.abs(Math.floor(duration / range))
        const timer = setInterval(() => {
          current += increment
          callback(current)
          if (current === end) {
            clearInterval(timer)
          }
        }, stepTime)
      }

      function showError(message) {
        const transition = transitionHelper({
          classNames: ["error-transition"],

          updateDOM: () => {
            errorMessage.textContent = message
            errorMessage.classList.add("active")
          },
        })

        transition.ready.finally(() => {
          errorMessage.style.scale = 1
        })

        transition.finished.finally(() => {
          setTimeout(() => {
            errorMessage.style.scale = 0
          }, 2400)
          setTimeout(() => {
            errorMessage.classList.remove("active")
          }, 3000)
        })
      }

      function validateStep(step) {
        let isValid = true
        const inputs = steps[step].querySelectorAll("input")
        inputs.forEach((input) => {
          if (!input.value) {
            input.classList.add("error")
            isValid = false
          } else {
            input.classList.remove("error")
          }
        })
        return isValid
      }

      function transitionHelper({
        skipTransition = false,
        classNames = [],
        updateDOM,
      }) {
        if (skipTransition || !document.startViewTransition) {
          const updateCallbackDone = Promise.resolve(updateDOM()).then(() => {})

          return {
            ready: Promise.reject(Error("View transitions unsupported")),
            updateCallbackDone,
            finished: updateCallbackDone,
            skipTransition: () => {},
          }
        }

        document.documentElement.classList.add(...classNames)

        const transition = document.startViewTransition(updateDOM)

        transition.finished.finally(() =>
          document.documentElement.classList.remove(...classNames)
        )

        return transition
      }

      function updateDOM(newStep) {
        if (newStep < 0 || newStep >= steps.length) return
        steps[currentStep].classList.remove("active")
        steps[newStep].classList.add("active")
        currentStep = newStep

        prevButton.style.display = currentStep === 0 ? "none" : "inline-block"

        nextButton.style.display =
          currentStep === steps.length - 1 ? "none" : "inline-block"

        submitButton.style.display =
          currentStep === steps.length - 1 ? "inline-block" : "none"

        progressBar.style.width = `${(currentStep / (steps.length - 1)) * 100}%`
        progressBar.style.backgroundColor =
          currentStep === steps.length - 1 ? "green" : "#333"

        animateNumber(
          parseInt(progressNumber.textContent), // start
          Math.floor((currentStep / (steps.length - 1)) * 100), // end
          700, // duration
          (value) => {
            progressNumber.textContent = `${value}%`
            progressNumber.style.setProperty("--progress-left", `${value}%`)
          } // callback
        )
      }
    </script>
  </body>
</html>

Check out this pin by Jesse Pence (@jesse-pence) on CodePen.

So, we added error handling and a progress bar. We change the background color based on whether the user makes an error, goes backward or forward, or reaches the end of the form. We also added not one but two progress indicators: a progress bar and a number which automatically animates as the user progresses through the form. Almost everything has a view-transition-name, so all of the elements adapt in terms of their position and size as the user goes through the form.

5-view-transitions-api

Overall, this is probably over-extending the View Transitions API. Should we be using it for all of this? Probably not. But it’s a fun example of what you can do with it. And, the most exciting part is coming up next!

MPA View Transitions

Finally, the holy grail! We saw earlier that, even with an extensive use of JavaScript, it’s almost impossible to get a seamless transition between pages in a multi-page application. To persist an element, we had to store a reference to it’s appearance in localStorage. Then, we had to manually animate the position of the element. But, the View Transitions API aims to make this much easier. It all comes down to the use of one special meta tag in the head of your HTML document.

<meta name="view-transition" content="same-origin" />

This meta tag tells the browser that you want to use View Transitions for this page. Now, all the same rules that we have been applying in our other examples apply when we navigate to other pages that have this meta tag on the same origin (whatever.com). So, let’s take a look at how that previous example would work if we add that meta tag and a few view-transition-names to each page.

Unfortunately, this feature doesn’t work in iFrames yet, so you’ll have to open this in a new tab.

Simple MPA Transition

index.html

<!DOCTYPE html>
<html>
  <head>
    <meta name="view-transition" content="same-origin" />
    <style>
      body {
        margin: 0;
      }
      main {
        display: flex;
        background: #990033;
        height: 100vh;
      }
      main a {
        color: #fff;
        background: #339909;
        height: fit-content;
        padding: 1em;
        margin: 1em;
        border-radius: 2em;
        view-transition-name: button;
      }
      main span {
        align-self: end;
        color: #fff;
        background: #330099;
        height: fit-content;
        border-radius: 4em;
        view-transition-name: other;
      }
    </style>
  </head>
  <body>
    <main>
      <a href="./other">Other</a>
      <span>HI</span>
    </main>
  </body>
</html>

other.html

<!DOCTYPE html>
<html>
  <head>
    <meta name="view-transition" content="same-origin" />
    <style>
      body {
        margin: 0;
      }
      main {
        display: flex;
        justify-content: flex-end;
        align-items: end;
        background: #339909;
        height: 100vh;
      }
      main a {
        color: #00f;
        background: #990033;
        height: fit-content;
        padding: 1em;
        margin: 1em;
        font-size: 2em;
        border-radius: 0.5em;
        view-transition-name: button;
      }
      main span {
        color: #fff;
        align-self: start;
        background: #f0f;
        height: fit-content;
        padding: 2em;
        margin: 2em;
        font-size: 3em;
        border-radius: 2em;
        view-transition-name: other;
      }
    </style>
  </head>
  <body>
    <main>
      <a href="./">Home</a>
      <span>BYE!</span>
    </main>
  </body>
</html>

50 lines of code for each page, with only three of them being animation-specific, and we have a fully animated multi-page application. It doesn’t look great, but this is just intended to show how easy this can be. And, we’re not restricted to simple animations. We can do some pretty complex stuff with it. Rich Harris, the creator of Svelte, recently gave a fantastic talk where he mentioned the View Transitions API. As an example of something that was impossible even with this new API, he described a survey in an MPA with a progress number tweening between pages.

Within two days, Jake Archibald had built a beautiful SPA demo. He started on an MPA version which doesn’t have any JS at all! It’s great, but it doesn’t have number tweening.

Here’s my attempt at an MPA version. The number tweening isn’t perfect, but it’s definitely there. And, this is all still rough and unpolished. As the API progresses, I’m sure that better designers than me will find ways to make this look even better. But, I think it’s a great example of the exciting things that are becoming possible with this API.

Jake Archibald's Survey Demo as an MPA

index.html

<!DOCTYPE html>
<html lang="en" style="--step: 1">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="view-transition" content="same-origin" />
    <link rel="icon" href="https://glitch.com/favicon.ico" />
    <title>Demo</title>
    <link rel="stylesheet" href="/survey/base-styles.css" />
    <link rel="stylesheet" href="/survey/styles.css" />
    <script type="module" src="/survey/script.js"></script>
  </head>
  <body>
    <header class="main-header">
      <span class="main-header-text">Jake Archibald's Survey Demo</span>
    </header>
    <main class="content">
      <h1 class="content-title">Step 1</h1>
      <ol>
        <li><a href="./">Step 1</a></li>
        <li><a href="./step-2.html">Step 2</a></li>
        <li><a href="./step-3.html">Step 3</a></li>
        <li><a href="./step-4.html">Step 4</a></li>
        <li><a href="./step-5.html">Step 5</a></li>
        <li><a href="./step-6.html">Step 6</a></li>
        <li><a href="./step-7.html">Step 7</a></li>
      </ol>
    </main>
    <div class="survey-progress">
      <div class="survey-ticks">
        <div style="--tick-pos: 0"></div>
        <div style="--tick-pos: 1"></div>
        <div style="--tick-pos: 2"></div>
        <div style="--tick-pos: 3"></div>
        <div style="--tick-pos: 4"></div>
        <div style="--tick-pos: 5"></div>
        <div style="--tick-pos: 6"></div>
        <div class="survey-current" style="--tick-pos: calc(var(--step) - 1)">
          <div class="survey-current-nums"></div>
        </div>
      </div>
    </div>
  </body>
</html>

step-2.html

<!DOCTYPE html>
<html lang="en" style="--step: 2">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="view-transition" content="same-origin" />

    <link rel="icon" href="https://glitch.com/favicon.ico" />
    <title>Demo</title>
    <link rel="stylesheet" href="/survey/base-styles.css" />
    <link rel="stylesheet" href="/survey/styles.css" />
    <script type="module" src="/survey/script.js"></script>
  </head>
  <body>
    <header class="main-header">
      <span class="main-header-text">Jake Archibald's Survey Demo</span>
    </header>
    <main class="content">
      <h1 class="content-title">Step 2</h1>
      <ol>
        <li><a href="./">Step 1</a></li>
        <li><a href="./step-2.html">Step 2</a></li>
        <li><a href="./step-3.html">Step 3</a></li>
        <li><a href="./step-4.html">Step 4</a></li>
        <li><a href="./step-5.html">Step 5</a></li>
        <li><a href="./step-6.html">Step 6</a></li>
        <li><a href="./step-7.html">Step 7</a></li>
      </ol>
    </main>
    <div class="survey-progress">
      <div class="survey-ticks">
        <div style="--tick-pos: 0"></div>
        <div style="--tick-pos: 1"></div>
        <div style="--tick-pos: 2"></div>
        <div style="--tick-pos: 3"></div>
        <div style="--tick-pos: 4"></div>
        <div style="--tick-pos: 5"></div>
        <div style="--tick-pos: 6"></div>
        <div class="survey-current" style="--tick-pos: calc(var(--step) - 1)">
          <div class="survey-current-nums"></div>
        </div>
      </div>
    </div>
  </body>
</html>

step-3.html

<!DOCTYPE html>
<html lang="en" style="--step: 3">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="view-transition" content="same-origin" />

    <link rel="icon" href="https://glitch.com/favicon.ico" />
    <title>Demo</title>
    <link rel="stylesheet" href="/survey/base-styles.css" />
    <link rel="stylesheet" href="/survey/styles.css" />
    <script type="module" src="/survey/script.js"></script>
  </head>
  <body>
    <header class="main-header">
      <span class="main-header-text">Jake Archibald's Survey Demo</span>
    </header>
    <main class="content">
      <h1 class="content-title">Step 3</h1>
      <ol>
        <li><a href="./">Step 1</a></li>
        <li><a href="./step-2.html">Step 2</a></li>
        <li><a href="./step-3.html">Step 3</a></li>
        <li><a href="./step-4.html">Step 4</a></li>
        <li><a href="./step-5.html">Step 5</a></li>
        <li><a href="./step-6.html">Step 6</a></li>
        <li><a href="./step-7.html">Step 7</a></li>
      </ol>
    </main>
    <div class="survey-progress">
      <div class="survey-ticks">
        <div style="--tick-pos: 0"></div>
        <div style="--tick-pos: 1"></div>
        <div style="--tick-pos: 2"></div>
        <div style="--tick-pos: 3"></div>
        <div style="--tick-pos: 4"></div>
        <div style="--tick-pos: 5"></div>
        <div style="--tick-pos: 6"></div>
        <div class="survey-current" style="--tick-pos: calc(var(--step) - 1)">
          <div class="survey-current-nums"></div>
        </div>
      </div>
    </div>
  </body>
</html>

step-4.html

<!DOCTYPE html>
<html lang="en" style="--step: 4">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="view-transition" content="same-origin" />

    <link rel="icon" href="https://glitch.com/favicon.ico" />
    <title>Demo</title>
    <link rel="stylesheet" href="/survey/base-styles.css" />
    <link rel="stylesheet" href="/survey/styles.css" />
    <script type="module" src="/survey/script.js"></script>
  </head>
  <body>
    <header class="main-header">
      <span class="main-header-text">Jake Archibald's Survey Demo</span>
    </header>
    <main class="content">
      <h1 class="content-title">Step 4</h1>
      <ol>
        <li><a href="./">Step 1</a></li>
        <li><a href="./step-2.html">Step 2</a></li>
        <li><a href="./step-3.html">Step 3</a></li>
        <li><a href="./step-4.html">Step 4</a></li>
        <li><a href="./step-5.html">Step 5</a></li>
        <li><a href="./step-6.html">Step 6</a></li>
        <li><a href="./step-7.html">Step 7</a></li>
      </ol>
    </main>
    <div class="survey-progress">
      <div class="survey-ticks">
        <div style="--tick-pos: 0"></div>
        <div style="--tick-pos: 1"></div>
        <div style="--tick-pos: 2"></div>
        <div style="--tick-pos: 3"></div>
        <div style="--tick-pos: 4"></div>
        <div style="--tick-pos: 5"></div>
        <div style="--tick-pos: 6"></div>
        <div class="survey-current" style="--tick-pos: calc(var(--step) - 1)">
          <div class="survey-current-nums"></div>
        </div>
      </div>
    </div>
  </body>
</html>

step-5.html

<!DOCTYPE html>
<html lang="en" style="--step: 5">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="view-transition" content="same-origin" />

    <link rel="icon" href="https://glitch.com/favicon.ico" />
    <title>Demo</title>
    <link rel="stylesheet" href="/survey/base-styles.css" />
    <link rel="stylesheet" href="/survey/styles.css" />
    <script type="module" src="/survey/script.js"></script>
  </head>
  <body>
    <header class="main-header">
      <span class="main-header-text">Jake Archibald's Survey Demo</span>
    </header>
    <main class="content">
      <h1 class="content-title">Step 5</h1>
      <ol>
        <li><a href="./">Step 1</a></li>
        <li><a href="./step-2.html">Step 2</a></li>
        <li><a href="./step-3.html">Step 3</a></li>
        <li><a href="./step-4.html">Step 4</a></li>
        <li><a href="./step-5.html">Step 5</a></li>
        <li><a href="./step-6.html">Step 6</a></li>
        <li><a href="./step-7.html">Step 7</a></li>
      </ol>
    </main>
    <div class="survey-progress">
      <div class="survey-ticks">
        <div style="--tick-pos: 0"></div>
        <div style="--tick-pos: 1"></div>
        <div style="--tick-pos: 2"></div>
        <div style="--tick-pos: 3"></div>
        <div style="--tick-pos: 4"></div>
        <div style="--tick-pos: 5"></div>
        <div style="--tick-pos: 6"></div>
        <div class="survey-current" style="--tick-pos: calc(var(--step) - 1)">
          <div class="survey-current-nums"></div>
        </div>
      </div>
    </div>
  </body>
</html>

step-6.html

<!DOCTYPE html>
<html lang="en" style="--step: 6">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="view-transition" content="same-origin" />

    <link rel="icon" href="https://glitch.com/favicon.ico" />
    <title>Demo</title>
    <link rel="stylesheet" href="/survey/base-styles.css" />
    <link rel="stylesheet" href="/survey/styles.css" />
    <script type="module" src="/survey/script.js"></script>
  </head>
  <body>
    <header class="main-header">
      <span class="main-header-text">Jake Archibald's Survey Demo</span>
    </header>
    <main class="content">
      <h1 class="content-title">Step 6</h1>
      <ol>
        <li><a href="./">Step 1</a></li>
        <li><a href="./step-2.html">Step 2</a></li>
        <li><a href="./step-3.html">Step 3</a></li>
        <li><a href="./step-4.html">Step 4</a></li>
        <li><a href="./step-5.html">Step 5</a></li>
        <li><a href="./step-6.html">Step 6</a></li>
        <li><a href="./step-7.html">Step 7</a></li>
      </ol>
    </main>
    <div class="survey-progress">
      <div class="survey-ticks">
        <div style="--tick-pos: 0"></div>
        <div style="--tick-pos: 1"></div>
        <div style="--tick-pos: 2"></div>
        <div style="--tick-pos: 3"></div>
        <div style="--tick-pos: 4"></div>
        <div style="--tick-pos: 5"></div>
        <div style="--tick-pos: 6"></div>
        <div class="survey-current" style="--tick-pos: calc(var(--step) - 1)">
          <div class="survey-current-nums"></div>
        </div>
      </div>
    </div>
  </body>
</html>

step-7.html

<!DOCTYPE html>
<html lang="en" style="--step: 7">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="view-transition" content="same-origin" />

    <link rel="icon" href="https://glitch.com/favicon.ico" />
    <title>Demo</title>
    <link rel="stylesheet" href="/survey/base-styles.css" />
    <link rel="stylesheet" href="/survey/styles.css" />
    <script type="module" src="/survey/script.js"></script>
  </head>
  <body>
    <header class="main-header">
      <span class="main-header-text">Jake Archibald's Survey Demo</span>
    </header>
    <main class="content">
      <h1 class="content-title">Step 7</h1>
      <ol>
        <li><a href="./">Step 1</a></li>
        <li><a href="./step-2.html">Step 2</a></li>
        <li><a href="./step-3.html">Step 3</a></li>
        <li><a href="./step-4.html">Step 4</a></li>
        <li><a href="./step-5.html">Step 5</a></li>
        <li><a href="./step-6.html">Step 6</a></li>
        <li><a href="./step-7.html">Step 7</a></li>
      </ol>
    </main>
    <div class="survey-progress">
      <div class="survey-ticks">
        <div style="--tick-pos: 0"></div>
        <div style="--tick-pos: 1"></div>
        <div style="--tick-pos: 2"></div>
        <div style="--tick-pos: 3"></div>
        <div style="--tick-pos: 4"></div>
        <div style="--tick-pos: 5"></div>
        <div style="--tick-pos: 6"></div>
        <div class="survey-current" style="--tick-pos: calc(var(--step) - 1)">
          <div class="survey-current-nums"></div>
        </div>
      </div>
    </div>
  </body>
</html>

base-styles.css

/* THIS IS ALL JAKE ARCHIBALD'S CODE!! 

I DIDN'T WRITE THIS, I JUST ADDED SOME SHITTY JS!! */

:root {
  --white: #fff;
  --primary-text: #212121;
  --secondary-text: #757575;
  --divider: #bdbdbd;
  --primary-color: #673ab7;
  --primary-dark: #512da8;
  --primary-light: #d1c4e9;
  --accent-color: #ff9800;
  --content-padding: 1.2rem;
  
  color: var(--primary-text);
  font-family: system-ui, sans-serif;
  font-size: 16px;
  line-height: 1.5;
}

body {
  margin: 0;
}

* {
  -webkit-tap-highlight-color: transparent;
}

.main-header {
  align-items: center;
  background: var(--primary-color);
  color: var(--white);
  contain: paint;
  display: grid;
  height: 54px;
  padding: 0 var(--content-padding);
}

.back-and-title {
  align-items: center;
  display: grid;
  gap: 0.3rem;
  grid-template-columns: 31px 1fr;
  color: inherit;
  text-decoration: none;
}

.content {
  padding: var(--content-padding);
}

.content-title {
  font-size: 1.6rem;
  font-weight: 600;
  margin: 0;
  max-width: 43ch;
}

.back-icon {
  display: block;
  fill: var(--white);
}

.gallery {
  display: grid;
  gap: var(--content-padding);
  grid-template-columns: repeat(auto-fill,minmax(300px,1fr));
  margin: 1rem 0;
  padding: 0;
}

.gallery > li {
  display: block;
  box-shadow: 0 5px 12px rgb(0 0 0 / 34%);
}

.gallery a {
  color: inherit;
  text-decoration: none;
}

.gallery img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 16/9;
}

.square-gallery {
  grid-template-columns: repeat(auto-fill,minmax(230px,1fr));
}

.square-gallery img {
  aspect-ratio: 1/1;
  object-fit: cover;
}

.gallery-item-title {
  padding: 1rem;
  display: block;
  text-align: center;
}

.banner-img img {
  display: block;
  width: 100%;
  height: auto;
  max-width: 1170px;
}

.banner-img {
  display: block;
  background: var(--divider);
}

.content-alt {
  background: #d4bbff;
}

.body-grid {
  height: -webkit-fill-available;
}

.body-grid body {
  height: 100vh;
  height: -webkit-fill-available;
  display: grid;
  grid-template-rows: max-content 1fr;
}

@media (min-width: 530px) {
  .content-and-nav {
    display: grid;
    grid-template-columns: 1fr 180px;
  }
}

.main-nav {
  background: #d4bbff;
}

.main-nav ul {
  margin: 0;
  padding: 0;
}

.main-nav li {
  display: block;
}

.main-nav li a {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 0.7rem;
  padding: 1rem;
  color: #000;
  text-decoration: none;
}

.main-nav li a::before {
  content: '➡️';
}

styles.css

/* THIS IS ALL JAKE ARCHIBALD'S CODE!! 

I DIDN'T WRITE THIS, I JUST ADDED SOME SHITTY JS!! */
@property --num-to-display {
  inherits: false;
  initial-value: 0;
  syntax: "<integer>";
}

html,
body {
  min-height: 100svh;
}

body {
  display: grid;
  grid-template-rows: auto 1fr auto;
}

.survey-progress {
  --tick-size: 50px;
}

.survey-ticks {
  position: relative;
  height: var(--tick-size);
  margin: 50px 75px;
  --minor-tick-background: #d1d1d1;
  --ticks-count: 7;
}

.survey-ticks::before {
  content: "";
  position: absolute;
  inset: 0 0 auto 0;
  top: 50%;
  translate: 0 -50%;
  height: 2px;
  background: var(--minor-tick-background);
}

.survey-ticks > * {
  position: absolute;
  top: 50%;
  left: calc(100% * var(--tick-pos) / (var(--ticks-count) - 1));
  translate: -50% -50%;
  background: var(--minor-tick-background);
  width: var(--tick-size);
  height: var(--tick-size);
  border-radius: var(--tick-size);
}

.survey-ticks > .survey-current {
  --tick-size: 75px;
  background: #737373;
  overflow: clip;
  view-transition-name: survey-current;
}

@keyframes progress-counter {
  from {
    --num-to-display: calc(
      (var(--from-step, var(--step)) - 1) / (var(--ticks-count) - 1) * 100
    );
  }
  to {
    --num-to-display: calc((var(--step) - 1) / (var(--ticks-count) - 1) * 100);
  }
}

.survey-current-nums {
  height: 100%;
  font-size: 2rem;
  color: #fff;
  position: absolute;
  inset: 0;
  display: grid;
  text-align: center;
  align-content: center;
  counter-reset: yo var(--num-to-display);
  animation: progress-counter 500ms both ease;
}

.survey-current-nums::before {
  content: counter(yo);
}

::view-transition-group(*) {
  animation-duration: 500ms;
}

::view-transition-old(survey-current) {
  display: none;
}

::view-transition-new(survey-current) {
  animation: none;
}

script.js

const getStep = (path) => /step-(\d)\.html$/.exec(path)?.[1] || "1"
const fromStep = getStep(document.referrer)
const toStep = getStep(location.href)

document.documentElement.style.setProperty("--from-step", fromStep)
document.documentElement.style.setProperty("--step", toStep)

Conclusion

While it is still restricted to Google Chrome and Microsoft Edge browsers, the View Transitions API seems like a huge boon for the web as a whole. Animations are no longer a second-class citizen on the web, and we can finally build complex page transitions without having to rely on JavaScript. I think that we are finally starting to see the web catch up to native apps. Between this and the Navigation API, building applications that flow naturally has never been easier. If Firefox and Safari implement these APIs, putting them together in your app could be as simple as this.

export default function Router(navigateEvent) {
  const pathname = new URL(navigateEvent.destination.url).pathname
  navigateEvent.intercept({
    async handler() {
      document.startViewTransition(async () => {
        await Route(pathname)
      })
    },
  })
}

Obviously, this code still wouldn’t be optimal as it isn’t checking for hash changes or anything like that. But, I just wanted to show how easy things could be. Who needs a framework when web standards are this powerful?

While I don’t think this marks the end for frameworks like React and Vue, it will be easier than ever to build complex animations without them. And, I think that this is a great thing. Animations can be a powerful tool for communicating with users, and they can make the web feel more alive. This article has already gotten long enough, so I’ll end here. I’m excited to see where this goes in the future, and I hope that you are too!

Additional Resources

Table of Contents Comments View Source Code Next Page!