With the addition of the new Todo form appearing at the bottom of the Todos, and the delete action removing a Todo, we have a very functional app. It would be nice if those additions and removals had a little animation to emphasize what’s happening on the page. If there was a long list, we might miss the deletion, especially if a network request caused a delay in the removal of the Todo. We can hook into Turbo streams, and run some animations on these actions to make them appear and disappear.
Adding a Custom Turbo Action
Turbo allows for the addition of custom actions to enhance the experience. Each StreamAction is passed a StreamElement, which contains the Turbo Frame that was sent from the server. We’re going to add two action which we’ll call animated_append
and animated_remove
to match the append
and remove
actions. The animated version of the remove action will perform the same removal, but after some animations run that scale and change the height of the removed element. These animations could be customized depending on the design of the web app. The animated append is a little trickier, but it first adds the elements, and then immediately changes the height to 0 to height it, and then animates the scaling. Add the actions in application.js
:
Turbo.StreamActions.animated_remove = async function () {
this.targetElements.forEach(async (target) => {
target.animate(
[
{
transform: `scale(1)`,
transformOrigin: "top",
height: "auto",
opacity: 1.0,
},
{
transform: `scale(0.8)`,
opacity: 0.2,
height: "80%",
offset: 0.8,
},
{
transform: `scale(0)`,
transformOrigin: "top",
height: 0,
opacity: 0,
},
],
{
duration: 75,
easing: "ease-out",
}
);
await Promise.all(
target.getAnimations().map((animation) => animation.finished)
);
target.remove();
});
};
Turbo.StreamActions.animated_append = async function () {
this.removeDuplicateTargetChildren();
this.targetElements.forEach(async (target) => {
target.append(this.templateElement.content);
target.lastElementChild.animate(
[
{
transform: `scaleY(0.0)`,
transformOrigin: "top",
height: "0",
opacity: 0.0,
},
{
transform: `scale(0.8)`,
opacity: 0.2,
height: "80%",
offset: 0.2,
},
{
transform: `scaleY(1)`,
transformOrigin: "top",
height: "auto",
opacity: 1,
},
],
{
duration: 100,
easing: "ease-out",
}
);
});
};
Sending the frame actions
The next bit is to send the frames from the server with those actions. An animated append of the new Todo form can be as simple as:
<turbo-stream action="animated_append" target="todos">
<template>
<%= turbo_frame_tag 'new_todo', target: "_top", class: "flex items-center py-3 pl-3 bg-white border-b pr-11 gap-x-4 todo" do %>
<%= render partial: 'form', locals: { todo: @todo } %>
<% end %>
</template>
</turbo-stream>
And removing a Todo from the list could be this:
<turbo-stream action="animated_remove" target="todo_101">
<template></template>
</turbo-stream>
The action
field on the Turbo Stream needs to correspond to the name of the actions we added in application.js
.
Adding helpers in Rails
We can add custom helpers in our Rails app to make those templates available. Add a helper file in app/helpers/
called turbo_streams_actions_helper.rb
. We’ll append two actions that will mirror the TagBuilder
in Turbo Rails.
module TurboStreamsActionsHelper
module CustomTurboStreamActions
def animated_remove(target)
action :animated_remove, target, allow_inferred_rendering: false
end
def animated_append(target, content = nil, **, &block)
action(:animated_append, target, content, **, &block)
end
end
Turbo::Streams::TagBuilder.prepend(CustomTurboStreamActions)
end
We can use the actions like any other Turbo Stream action.
<%= turbo_stream.append "todos" do %>
becomes
<%= turbo_stream.animated_append "todos" do %>
in app/views/todos/new.html.erb
And in todos_controller.rb
, the destroy action changes from format.turbo_stream { render turbo_stream: turbo_stream.remove(@todo) }
to format.turbo_stream { render turbo_stream: turbo_stream.animated_remove(@todo) }
.
Progressively Enhancing
The neat bit about this change is that we don’t need to completely rewrite the partials to get animations as the page changes. We go from portions of the page the appear suddenly or leave suddenly to a pleasant coming and going look to help the app feel more alive, and more similar to what you’d expect in a mobile application.