Turbo View Transitions in Rails

I'm trying to improve my design engineering and have been practicing and looking for opportunities to flex and grow. One of my favorite new techniques is View Transitions, a simple way using CSS to animate transitions between states of the view, whether it's a full page reload or a DOM update. I happen to love JS but I want to write as little of it as possible, especially when it comes to adding and removing classes to facilitate animations. So view transitions really speak to me.

Support for view transitions just hit the Turbo library with the release of Turbo 2.0. Along with DOM morphing support and combined with the rest of Rails, it's a powerful combination where you can achieve some impressive reactivity with really minimal effort, code, and complexity. Let me show you.

All the code for the application is on GitHub and you can see a demo of view transitions with turbo.

Everything here applies to Rails 7 and Turbo 2.0. It's worth upgrading your applications to the modern Rails stack (I just did an update from 6 to main and besides some stuff with webpacker, it really wasn't that bad).

Setup

Our application has Photos that have URLs and likes count. In db/seed.rb I create a few photos. There's also a PhotosController that has index, show and update actions. That's about all you need to know.

Classic View Transitions

The transition we want to implement is the one between the index and show views. When you click on a photo in the index, it should animate the transition to the show view. The first step to accomplish this is to add <meta name="view-transition" content="same-origin" /> to your layout. With that, having nothing to do with Rails, you actually will already get a nice fade transition between the two views as that's the default view transition.

Fade Transition

There are great articles on how view transitions work so I'm not going to cover the default use-case in detail.

The basics are that the browser is taking a screenshot of the current page and a screenshot of thew new page and transitioning them between two CSS pseudo-elements of ::view-transition-old and ::view-transition-new. The browser then animates the transition between the two screenshots, the default being a fade. The browser is apparently really great at this effect as we will see.

Focusing the Transition to an Element

Rather than fading the entire page between views, we can focus the transition on a specific element. You're telling the browser to explicity focus the transition of the element from the old to the new view. All you have to do is give the presence of the elements you want to focus the transition on the same view-transition-name property.

This actually took me a second to understand how to use correctly but in our example, what we want to do is tell the browser that the thumbnail of the photo is being transitioned to the full photo element. Instead of just fading the entire page, the browser will focus the transition on moving the thumbnail of the photo into the full photo, which creates a lovely effect of the thumbnail moving and growing into the full photo.

Element Transition

I updated the thumbnail to have a unique view-transition-name property based on the photo id.

app/views/photos/index.html.erb

<img
  class="h-auto max-w-full rounded-lg"
  src="<%= photo.url %>"
  alt="<%= photo.name %>"
  style="view-transition-name: photo_<%= photo.id %>"
>

Now that the thumbnail has a unique view-transition-name, we can tell the browser to focus the transition on the full photo element by giving it the same name.

app/views/photos/_photo.html.erb

content_tag :div, 
  class: "photo-viewer", 
  style: "view-transition-name: #{dom_id(photo)}", 
  id: dom_id(photo)

That's it. Now when you click on a photo, the transition will focus on the thumbnail and animate it into the full photo.

Turbo Frames and Custom Transitions

For my next trick, let's use a custom view transition animation for an element within a turbo frame by implementing an updating "Like" button.

app/views/photos/_photo.html.erb

<%= turbo_frame_tag dom_id(photo, :likes) do %>
  <div class="photo-viewer__like-button" style="view-transition-name: zoom">
    <%= form_for(photo) do |f| %>
      <%= f.button type: 'submit', class: "like-button__link" do %>
        <span class="like-button__icon">❤️</span>
        <span class="like-button__count"><%= photo.likes_count %></span>
      <% end %>
    <% end %>
  </div>
<% end %>

When you click the Like button, it will submit the form looking for the turbo frame with the same id in the response in order to update just the frame contents. After the server updates likes_count, it sends back photos/show.html.erb again which contains the same turbo frame and thus that is the only element to update. Just standard turbo frame magic. If you're curious, here's photos#update, nothing special.

def update
  @photo.increment(:likes_count)
  @photo.save
  redirect_to photo_path(@photo)
end

If you noticed, the element within the turbo frame has a view-transition-name of zoom.

<div class="photo-viewer__like-button" 
  style="view-transition-name: zoom">

This means this element will be animated with a custom view transition we can define called zoom.

But before we can define that zoom transition, we do have to tell Turbo to actually fire the view transition when the turbo frame updates. From How to use View Transitions in Hotwire Turbo:

We need to override the default rendering function for Turbo Frames in the turbo:before-frame-render event handler with a custom one that utilizes View Transitions.

In app/javascript/controllers/application.js:

addEventListener("turbo:before-frame-render", (event) => {
  if (document.startViewTransition) {
    const originalRender = event.detail.render;
    event.detail.render = (currentElement, newElement) => {
      document.startViewTransition(() => originalRender(currentElement, newElement));
    };
  }
});

The handler code first checks whether View Transitions are supported by the browser and if so, it wraps the original rendering function with the document.startViewTransition function. Now when a frame is rendered, the browser will use view transitions to animate the changes.

With that, we can define the zoom transition in our CSS.

app/assets/stylesheets/application.tailwind.css

@keyframes zoomIn {
  from {
    transform: scale(0.5);
    opacity: 0;
  }
  to {
    transform: scale(1);
    opacity: 1;
  }
}

@keyframes zoomOut {
  from {
    transform: scale(1);
    opacity: 1;
  }
  to {
    transform: scale(0.5);
    opacity: 0;
  }
}

::view-transition-new(zoom) {
  animation: zoomIn 0.5s ease forwards;
}

::view-transition-old(zoom) {
  animation: zoomOut 0.5s ease forwards;
}

And viola! We get a really nice effect when the like button is clicked all the while only updating the turbo-frame content.

Zoom Transition

Turbo Streams and Real-Time Updates

But wait, there's more! We can make the like button update in real-time when another user likes the photo and still have the same view transition firing to animate the change.

First, let's implement the real-time updates. Hold on because it's really complicated with Rails (sarcasm).

Subscribe photos/show to a stream for the photo:

<%= turbo_stream_from @photo %>

Tell the Photo model to broadcast a refresh whenever an instance of Photo is changed.

class Photo < ApplicationRecord
  broadcasts_refreshes
end

And then...well that's it.

Real-Time Update

We're not done, let's make this even better.

First, now that we're using turbo streams and broadcasting the changes, we can entirely remove the turbo frame from the view. The form will submit and the turbo stream will update the like count and the button on the page you are on as well as any other browser that is viewing the same photo.

Second, we're updating a lot of DOM in this interaction because the turbo stream is broadcasting an entire page update when all that has changed is literally the number inside the like button. Wouldn't it be amazing if we could just update that number and change nothing else? You guessed it, we can. Turbo 8 ships with DOM morphing built-in, we just need to enable it.

To enable this, we just add <%= turbo_refreshes_with method: :morph, scroll: :preserve %> to application.html.erb layout.

Now pay attention to what DOM gets updated when like button is pressed.

Real-Time Update with Morphing

Ya, that's right, it's only updating the content of the like button's count (and the form authenticity token because that changed too). Otherwise, nothing about the page's DOM is changed. This is a huge win for performance and user experience. And we literally implemented this with one line of code and 0 Javascript.

Conclusion

Just stop and think for a second about what we've accomplished here. We have an element that will update in real-time across browsers and animate itself and we wrote no Javascript. In fact, we barely wrote any code to accomplish this at all.

The real-time update with morphing totaled 3 lines of code.

  1. Subscribe to the stream.
  2. Broadcast the refresh.
  3. Enable morphing.

All the animations are handled by view transitions. And that's just one of the features, let's not forget where the post started with the cool transition between the thumbnail and the photo.

If you enjoyed this post, please follow me on X/Twitter for more. I'm also available for contract work.

Did you find this article valuable?

Support code.avi.nyc by becoming a sponsor. Any amount is appreciated!