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.