The future of full-stack Rails II: Turbo View Transitions
Translations
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:
- The future of full-stack Rails: Turbo Morph Drive
- 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.
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:
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:
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:
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:
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:
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!