The future of full-stack Rails II: Turbo View Transitions

Cover for The future of full-stack Rails II: Turbo View Transitions

The Web continues to evolve at a fast pace. New, exciting features are being proposed and adopted by web browsers at regular intervals. One of the hottest new browser APIs to become widely available in 2023 was View Transitions. Let’s see how we can leverage this futuristic technology to supercharge our Turbo applications!

Other parts:

  1. The future of full-stack Rails: Turbo Morph Drive
  2. The future of full-stack Rails II: Turbo View Transitions

During this series, we’ll try to imagine how the future of Ruby on Rails full-stack applications will look, the technologies they’ll use (beyond Hotwire), and how this will affect user experiences. In the previous post, we introduced the Turbo Music Drive application and enhanced it with DOM morphing techniques to provide a smoother UX. Now, we’ll take it to the next level and add slick animations.

Go to turbo-music-drive.fly.dev to see the app in action 🎧.

Let’s start with a quick introduction to the View Transitions API.

View Transitions in a nutshell

View Transitions is a new (and still marked as “experimental”) browser API to animate page transitions. What is a page transition? It’s the act of moving from one document’s state to another. For example, navigation between web pages is a transition. Updating a part of a page could also be considered a transition (for example, when we update the contents of a modal window or a sidebar panel).

We’ve already had tools to bring life into page transitions (i.e., to animate them): these include CSS animations and transitions, the recently introduced Web Animations API, and plenty of JavaScript-based solutions. And at present, all of these tools can be used to animate DOM tree elements on the page. So, what’s the need for a new API, then?

Compared to previous techniques, View Transitions operates on a different level. Instead of animating actual HTML elements, we capture screenshots of the old and new states of the page and animate them. The benefit of this approach is that we have access to both states simultaneously without modifying the DOM tree itself.

The transition screenshots are added to the DOM tree as pseudo-elements (::view-transition, ::view-transition-new, ::view-transition-old, etc.). We’re not going into details here; you can find them on MDN. What’s important is that we can control the animation of these pseudo-elements using CSS. You’ll see examples in a moment.

View Transition pseudo-elements in a DOM tree

View Transition pseudo-elements in a DOM tree

Turbo Drive meets View Transitions

Let’s move from theory to practice and see how we can enhance our Turbo-driven navigation with View Transitions.

The actual API that browsers provide is minimalist: just a single document.startViewTransition function. This function accepts a callback to perform a page update as its only argument, and that’s it. Given this, we only need to inject a bit of custom logic into the Turbo rendering process. And for that, we can use the same turbo:before-render event listener we added to introduce morphing in the previous part:

document.addEventListener("turbo:before-render", (event) => {
  event.detail.render = async (prevEl, newEl) => {
    await new Promise((resolve) => setTimeout(() => resolve(), 0));
    morphRender(prevEl, newEl);
  };

  if (document.startViewTransition) {
    // Make sure rendering is synchronous in this case
    // to ensure it's executed within the `startViewTransition` call boundaries
    event.detail.render = (prevEl, newEl) => {
      morphRender(prevEl, newEl);
    };

    event.preventDefault();

    document.startViewTransition(() => {
      event.detail.resume();
    });
  }
});

document.addEventListener("turbo:load", () => {
  if (document.head.querySelector('meta[name="view-transition"]'))
    Turbo.cache.exemptPageFromCache();
});

First, we check if the API is available. Then, we pause the default Turbo rendering process by calling event.preventDefault(). Finally, we invoke document.startViewTransition, wrapping the call to event.detail.resume()—this is how Turbo allows us to resume the rendering.

We also added a turbo:load listener to exclude the page from a Turbo cache if it contains a meta[name="view-transition"] tag. We use this meta tag to comply with the multi-page View Transitions API, which uses it to decide if the page should be animated or not. Why turn off cache? When Turbo performs navigation, it first replaces the contents with the cached version of the target page (if present) and only renders the new state as the corresponding HTTP request finishes. Thus, there may be two DOM updates within the startViewTransition operation, which would break the animation.

Okay, let’s see if simply wrapping page updates into the startViewTransition call changes anything:

Full-page transitions and using Chrome DevTools to control animations

As you can see, the page transition is now animated! By default, View Transitions apply a fade-out/fade-in animation to the old and new states of the page, respectively—looks like a sensible default, right?

In the video above, you can also see how to use Chrome DevTools to control the speed of the animations and even how to pause them. This is very handy when you’re experimenting with View Transitions.

The functionality we’ve just added to our app will be a part of the upcoming Turbo 8 (the PR was merged a while ago). But that’s not the only use case for View Transitions. We can also use them to animate specific parts of the page. Let’s see how we can do that and what challenges arise in HTML-driven applications.

Animating page fragments with turbo-view-transitions

With View Transitions, we can animate different parts of the page individually by defining a view-transition-name style on them. The value must be a unique identifier, which is used by the browser to match the old and new states for this page fragment.

For example, try adding style="view-transition-name: title to the <h2> element on the home page and the corresponding element on the artist page. You’ll see something like this:

Animating title transition

Looks a bit strange, but you get the point.

Now, let’s think about how we can implement album cover animations between pages, given that there can be more than one cover on the page. We can’t use the same view-transition-name, because it must be unique. So, let’s generate a unique name for each cover:

<div style="view-transition-name:<%= dom_id(album, :cover) %>">
<!-- ... -->
</div>

Unsurprisingly, it works:

Basic album covers animation

This approach has one significant limitation: we can not define custom animation logic in CSS because we don’t know the name of the transition in advance. To overcome this limitation, I came up with the following idea: we can use a custom attribute to define the name of the transition and attach the view-transition-name style to elements on the page only if they are present in both the old and new states. After the transition completes, we deactivate all the elements (i.e., remove the view-transition-name style). This is how the turbo-view-transitions library was born.

With turbo-view-transitions, you can use the data-turbo-transition attribute to declare that an element should be individually animated. The value of the attribute can be either the transition name (if you need customization) or an empty value (in this case, a transition name would be inferred from the element’s ID). To integrate the library into Turbo, you need to update the rendering logic as follows:

import {
  shouldPerformTransition,
  performTransition,
} from "turbo-view-transitions";

document.addEventListener("turbo:before-render", (event) => {
  // ... default rendering setup

  if (shouldPerformTransition()) {
    // ... rendering setup for transitions

    event.preventDefault();

    performTransition(document.body, event.detail.newBody, async () => {
      await event.detail.resume();
    });
  }
});

The changes are minimal: we use the shouldPerformTransition and performTransition functions from the library. The former checks if the API is available and the view-transition meta tag is present; the latter identifies the elements to be transitioned and calls startViewTransition under the hood.

To see it in action, let’s add the data-turbo-transition attribute to the album covers:

<div id="<%= dom_id(album, :cover) %>" data-turbo-transition="cover">
  <!-- ... -->
</div>

Note that we also need to add the id attribute to the element so we can differentiate between different covers when we calculate elements to be animated.

Let’s also define a custom CSS to animate covers:


@keyframes shake {
  0% {
    transform: translateX(0);
  }
  30% {
    transform: translateX(-10px);
  }
  60% {
    transform: translateX(10px);
  }
  90% {
    transform: translateX(-10px);
  }
  100% {
    transform: translateX(0);
  }
}

::view-transition-new(cover) {
  animation: 300ms ease-in 0ms both shake;
}

It’s time to see shaky covers in action:

Shaky album covers animation

Awesome! Bringing object transitions to Turbo applications with just a single data attribute is a huge win.

So far, we’ve only been discussing Turbo Drive. What about partial page updates via Turbo Frames and Turbo Streams? Can we leverage View Transitions to animate them, too? Yes, we can.

Turbo Streams meet View Transitions

One particular action that I’d like to animate in our Turbo Music Drive application is the player itself. Whenever a user chooses a track, we update the player by replacing its HTML contents with the HTML via Turbo Streams. It would be nice to animate this update, right?

Similarly to turbo:before-render and turbo:before-frame-render events, there is a turbo:before-stream-render event, which we can use to enhance rendering with transitions:

document.addEventListener("turbo:before-stream-render", (event) => {
  if (shouldPerformTransition()) {
    const fallbackToDefaultActions = event.detail.render;

    event.detail.render = (streamEl) => {
      if (streamEl.action == "update" || streamEl.action == "replace") {
        const [target] = streamEl.targetElements;

        if (target) {
          return performTransition(
            target,
            streamEl.templateElement.content,
            async () => {
              await fallbackToDefaultActions(streamEl);
            },
            { transitionAttr: "data-turbo-stream-transition" }
          );
        }
      }
      return fallbackToDefaultActions(streamEl);
    };
  }
});

The code above looks a bit more complicated than the one for Turbo Drive. We need to take action types into account (not every action is transitionable), and the rendering logic differs, too (there is no event.detail.resume). But the idea is the same: we wrap the default rendering logic into the performTransition call. One important difference is that we use a different attribute to search for elements to be transitioned: data-turbo-stream-transition instead of data-turbo-transition. This is because we don’t want to activate transition for elements updated via streams during the normal navigation.

Now, by adding the data-turbo-stream-transition="player" to the player container and defining the corresponding CSS animation, we can animate the player updates:

Animated player updates

That’s it. I’ll leave it up to the reader to explore the possibility of adding transitions to Turbo Frames updates.

Our “Future full-stack Rails” series is coming to an end. We’ve learned how to leverage modern web technologies to bring our Turbo-driven applications to the next level. And we didn’t even need to wait for Turbo 8 (or 9, or X) to come out! The Hotwire tooling is already flexible enough to allow us to experiment with fresh-from-the-oven technologies while they’re still piping hot out the oven. In other words, we can build the future ourselves!

The source code for the Turbo Music Drive application can be found on GitHub.

At Evil Martians, we transform growth-stage startups into unicorns, build developer tools, and create open source products. If you’re ready to engage warp drive, give us a shout!

Join our email newsletter

Get all the new posts delivered directly to your inbox. Unsubscribe anytime.