How to add animations to Hotwire's Turbo Streams

13 May 2021

Although Hotwire does not currently provide animations out of the box, it does give us hooks to add in our own.

I will show you how to add some simple animations to an example Rails app using Turbo Streams.


TL;DR

Turbo Streams allow us to hook into a turbo:before-stream-render event. From here we can apply animation classes to elements that are just about to be added, or just about to be removed.

document.addEventListener("turbo:before-stream-render", function(event) {
  // Add a class to an element we are about to add to the page
  // as defined by its "data-stream-enter-class"
  if (event.target.firstElementChild instanceof HTMLTemplateElement) {
    var enterAnimationClass = event.target.templateContent.firstElementChild.dataset.streamEnterClass
    if (enterAnimationClass) {
      event.target.templateElement.content.firstElementChild.classList.add(enterAnimationClass)
    }
  }

  // Add a class to an element we are about to remove from the page
  // as defined by its "data-stream-exit-class"
  var elementToRemove = document.getElementById(event.target.target)
  if (elementToRemove) {
    var streamExitClass = elementToRemove.dataset.streamExitClass
    if (streamExitClass) {
      // Intercept the removal of the element
      event.preventDefault()
      elementToRemove.classList.add(streamExitClass)
      // Wait for its animation to end before removing the element
      elementToRemove.addEventListener("animationend", function() {
        event.target.performAction()
      })
    }
  }
})

With the above snippet we can define a data-stream-enter-class and/or a data-stream-exit-class which will apply the classes we need. All of our animations can then be handled with CSS, without the need for any further custom JavaScript.


Let’s work through some examples.

This is what our dummy app looks like without animation:

Before - without animation

Example #1

Let’s first try to animate the cart panel on the right hand side.

When the first ticket is added, the cart will both fade in and slide in. And when the last ticket is removed it will fade and slide out.

Our + and - buttons trigger cart_items#create and cart_items#destroy respectively.

class CartItemsController < ApplicationController
  before_action :set_cart, :set_product

  def create
    @cart_item = @cart.cart_items.create!(product: @product)
  end

  def destroy
    @cart_item = @cart.cart_items.order(created_at: :desc).where(product: @product).first
    @cart_item.destroy
  end
end
<%# create.turbo_stream.erb %>
<% if @cart.cart_items.count == 1 %>
  <%= turbo_stream.append "cart-container",
        partial: "carts/cart",
        locals: { cart: @cart } %>
<% end %>

<%# destroy.turbo_stream.erb %>
<% if @cart.cart_items.count == 0 %>
  <%= turbo_stream.remove @cart %>
<% end %>

As per the JavaScript snippet above, we specify the animation classes we need in the cart partial:

<%# carts/_cart.html.erb %>
<% unless cart.empty? %>
  <%= tag.div id: dom_id(cart), class: "cart", data: {
        stream_enter_class: "animate-cart-in",
        stream_exit_class: "animate-cart-out"
  } do %>
    <%= render "carts/cart_items", cart: cart %>
    <%= render "carts/total", cart: cart %>
    <%= render "carts/book", cart: cart %>
  <% end %>
<% end %>

And define our CSS animation classes:

.animate-cart-in {
  animation: fade-in 0.25s ease-out,
             slide-in 0.25s ease-out;
}

.animate-cart-out {
  animation: fade-out 0.25s ease-out,
             slide-out 0.25s ease-out;
}

@keyframes fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

@keyframes fade-out {
  from { opacity: 1; }
  to { opacity: 0; }
}

@keyframes slide-in {
  from { transform: translateX(4rem); }
  to { transform: translateX(0); }
}

@keyframes slide-out {
  from { transform: translateX(0); }
  to { transform: translateX(4rem); }
}

The result:

Cart sliding and fading in and out

Example #2

Next we are going to slide down any tickets that are added (unless it is the first), and slide up any that are removed (unless it is the last).

To the create response, we need to append the cart item if it is not the first ticket:

<%# create.turbo_stream.erb %>
<% if @cart.cart_items.size == 1 %>
  <%= turbo_stream.append "cart-container",
        partial: "carts/cart",
        locals: { cart: @cart } %>
<% else %>
  <%= turbo_stream.append dom_id(@cart, :cart_items),
        partial: "cart_items/cart_item",
        locals: { cart_item: @cart_item } %>
<% end %>

For reference, our cart_items container partial:

<%# carts/_cart_items.html.erb %>
<%= tag.div id: dom_id(cart, :cart_items) do %>
  <%= render cart.cart_items %>
<% end %>

Similarly to the destroy response, we remove the cart item unless it is the last ticket:

<%# destroy.turbo_stream.erb %>
<% if @cart.cart_items.count == 0 %>
  <%= turbo_stream.remove @cart %>
<% else %>
  <%= turbo_stream.remove @cart_item %>
<% end %>

We then specify the enter and exit animation classes in our cart item partial:

<%# cart_items/_cart_item.html.erb %>
<%= tag.div id: dom_id(cart_item), data: {
      stream_enter_class: "animate-cart-item-in",
      stream_exit_class: "animate-cart-item-out"
} do %>
  <div class="flex p-2">
    <div class="flex-grow"><%= cart_item.product.name %></div>
    <div class="w-24 text-right font-bold"><%= cart_item.price %></div>
  </div>
<% end %>

And define our animation CSS classes:

.animate-cart-item-in {
  overflow: hidden;
  animation: slide-down 0.5s ease-out;
}

.animate-cart-item-out {
  overflow: hidden;
  animation: slide-up 0.5s ease-out;
}

/* "max-height: auto" unfortunately does not work here, so we set
   to a fixed height that is greater than the expected height. */
@keyframes slide-down {
  from { max-height: 0; }
  to { max-height: 3rem; }
}

@keyframes slide-up {
  from { max-height: 3rem; }
  to { max-height: 0; }
}

Which gives us:

Cart items sliding up and down

Example #3

Finally, let’s add an animation for flashing the total price. It will flash green if the total increases, and red if the total decreases.

In this example we only need an “enter” animation class. However, this class needs to change based on whether the total is increased or decreased.

We pass in a direction variable to the partial to handle this, which will be either “increase” or “decrease”:

<%# create.turbo_stream.erb %>
<% if @cart.cart_items.size == 1 %>
  <%= turbo_stream.append "cart-container",
        partial: "carts/cart",
        locals: { cart: @cart } %>
<% else %>
  <%= turbo_stream.append dom_id(@cart, :cart_items),
        partial: "cart_items/cart_item",
        locals: { cart_item: @cart_item } %>

  <%= turbo_stream.replace dom_id(@cart, :total),
        partial: "carts/total",
        locals: { cart: @cart, direction: "increase" } %>
<% end %>
<%# destroy.turbo_stream.erb %>
<% if @cart.cart_items.count == 0 %>
  <%= turbo_stream.remove @cart %>
<% else %>
  <%= turbo_stream.remove @cart_item %>

  <%= turbo_stream.replace dom_id(@cart, :total),
        partial: "carts/total",
        locals: { cart: @cart, direction: "decrease" } %>
<% end %>

And then use it to set our animation classes:

<%# carts/_total.html.erb %>
<%= tag.div id: dom_id(cart, :total), class: "total", data: {
      stream_enter_class: "animate-flash-#{local_assigns[:direction]}"
} do %>
  <div class="flex-grow font-bold">Total</div>
  <div class="w-24 text-right font-bold"><%= cart.total %></div>
<% end %>
.animate-flash-increase {
  animation: flash-green 0.5s ease-in-out;
}

.animate-flash-decrease {
  animation: flash-red 0.5s ease-in-out;
}

@keyframes flash-green {
  from { background-color: #A7F3D0; }
  to { background-color: transparent; }
}

@keyframes flash-red {
  from { background-color: #FECACA; }
  to { background-color: transparent; }
}

Flashing the total green and red

EDIT (Oct 2021) - A change to Turbo caused the snippet above to no longer work. It has now been updated. Thanks to chrism and coderifous for highlighting the fix.


Any questions or suggestions? Please feel free to get in touch -- ed@edforshaw.co.uk