With Turbo Streams you can update specific parts of your app. Inject a chat message, update a profile picture or insert a Report is being created alert.
The preciseness Turbo Streams offers is great. But often the abruptness of it, its not too appealing to me. The new component is just there (or isn’t, if you remove it).
I’d like to add a bit more joy to my apps and this technique is something that does just that. I previously explored multiple techniques to add some kind of transition or animation when an element was inserted or removed. I fine-tuned it over the years while using it in production. And I can say I’m happy with how the technique works I am outlining today.
First, this will be the end result:
And this is how it’s used:
<%= turbo_stream_action_tag_with_block "prepend", target: "resources", data: {transition_enter: "transition ease-in duration-300", transition_enter_start: "opacity-0", transition_enter_end: "opacity-100"} do %>
<%= render ResourceComponent.new(resource: @resource) %>
<% end %>
If you are using Rails Designer it is included for you and ready to go. Winning! 🏆
There are quite a few moving elements here to get it going, but as seen from the code example above, the usage is really clean and customizable.
Let’s get started.
The first step is to create the turbo_stream_action_tag_with_block
helper. This is needed, because the available turbo_stream_action_tag
helper doesn’t allow a block to be passed (eg. render CollectionComponent
) and the the default Turbo stream Rails helpers don’t pass along data-attributes. It’s simple enough though:
# app/helpers/turbo_stream_helper.rb
module TurboStreamHelper
def turbo_stream_action_tag_with_block(action, target: nil, targets: nil, **options, &block)
template_content = block_given? ? capture(&block) : nil
turbo_stream_action_tag(action, target: target, targets: targets, template: template_content, **options)
end
end
Next up is to add a listener for turbo:before-stream-render
and add some custom behavior.
// app/javascript/utilities/turbo_stream_render.js
document.addEventListener("turbo:before-stream-render", (event) => {
const { target } = event
if (!(target.firstElementChild instanceof HTMLTemplateElement)) return
const { dataset, templateElement } = target
const { transitionEnter, transitionLeave } = dataset
if (transitionEnter !== undefined) {
handleTransitionEnter(event, templateElement, dataset)
}
if (transitionLeave !== undefined) {
handleTransitionLeave(event, target, dataset)
}
})
const handleTransitionEnter = (event, templateElement, dataset) => {
event.preventDefault()
const firstChild = templateElement.content.firstElementChild
Object.assign(firstChild.dataset, dataset)
firstChild.setAttribute("hidden", "")
firstChild.setAttribute("data-controller", "appear")
event.target.performAction()
}
const handleTransitionLeave = (event, target, dataset) => {
const leaveElement = document.getElementById(target.target)
if (!leaveElement) return
event.preventDefault()
Object.assign(leaveElement.dataset, dataset)
leaveElement.setAttribute("data-controller", "disappear")
}
Wow! This looks scary! It’s not too bad really! It intercepts the turbo:before-stream-render
event, checking the target element’s dataset for specific transition attributes (eg. data-transition-start). For entering elements, it sets them to hidden and adds an appear data-controller. For leaving elements, it adds a disappear data-controller.
📚 Want to be more comfortable writing and understanding JavaScript as a Ruby on Rails developer? Check out the book JavaScript for Rails Developers
Make sure to import it into your application.js.
// app/javascript/application.js
import "@hotwired/turbo-rails"
import "./controllers"
import "./utilities/turbo_stream_render.js"
// …
Let’s create those two controllers now. They are really simple and rely on the cool el-transition library. Make sure to add it to your app (either via NPM or importmaps).
// app/javascript/controllers/appear_controller.js
import ApplicationController from "./application_controller"
import { enter } from "el-transtion"
export default class extends ApplicationController {
connect() {
enter(this.element)
}
}
// app/javascript/controllers/disappear_controller.js
import ApplicationController from "./application_controller"
import { leave } from "el-transtion"
export default class extends ApplicationController {
connect() {
leave(this.element).then(() => {
this.element.remove()
})
}
}
And with that all out of the way, you can now add smooth transitions to any added or removed element using turbo streams.
Simply append some data-attributes (data: {transition_enter: ""}
) to the turbo stream and enjoy a smooth ride.
You can use the same data-attributes supported by el-transition:
- data-transition-enter;
- data-transition-enter-start;
- data-transition-enter-end;
- data-transition-leave;
- data-transition-leave-start;
- data-transition-leave-end.
For adding elements:
<%= turbo_stream_action_tag_with_block "prepend", target: "resources", data: {transition_enter: "transition ease-in duration-300", transition_enter_start: "opacity-0", transition_enter_end: "opacity-100"} do %>
<%= render ResourceComponent.new(resource: @resource) %>
<% end %>
And for removing elements:
<%= turbo_stream_action_tag_with_block "remove", target: dom_id(@resource), data: {transition_leave: "transition ease-in duration-300", transition_leave_start: "opacity-100", transition_leave_end: "opacity-0"} %>
This all seems like a lot and I would agree. I believe Turbo would be better if it supported adding attributes to the template content’s element(s). This would make the custom behavior added in the listener unnecessary. Might be worth adding a PR to get first-party support for this.
Have more ideas to improve this? Anything I overlooked? Let me know!