I think animations would be a nice addition to Hotwire, especially for Turbo Frames and Streams. Things like fade in/out, slide down/up and highlights for content added or removed from the page.
I totally agree. I used Swup.js for a couple websites because of all the transition plugins, but that project seemed to stall a while ago. Would love to see similar enhancements for Turbo!
Made a proof-of-concept using animate.css
and a minor change to TurboâŚ
(Slowed down the animation for clarity, and Turbo broke the data-disable-with
attribute so the button loading indicator is broken.)
This is using a stream to replace a form, both for errors and a success state:
<%= turbo_stream.replace 'form' do %>
<% if @user.errors.any? %>
<%= render 'new_form.html' %>
<% else %>
<%= render 'new_success.html' %>
<% end %>
<% end %>
And an event handler listening to turbo:before-stream-render
:
document.addEventListener('turbo:before-stream-render', function(event) {
event.preventDefault()
let oldElement = document.getElementById(event.target.target)
oldElement.classList.add('animate__fadeOutLeft', 'animate__animated')
oldElement.addEventListener('animationend', function() {
oldElement.replaceWith(event.detail.newTemplate)
})
})
The incoming new_success.html
already has the animate.css
classes applied, and event.detail.newTemplate
technically doesnât exist so thatâs a slightly modified build of turbo.js
(https://github.com/hotwired/turbo/pull/20).
Note the âproof-of-conceptâ â Iâm using animate-in
and animate-out
attributes on elements to signify what should be animated so it doesnât apply to everything.
We donât have explicit plans for animation in Hotwire, but we would love to have the right hooks in place so that you (or a third-party library) can do whatever you want.
Thatâs exactly what we had in mind with the turbo:before-stream-render
event. We donât have a corresponding event for frames yet, but Iâm open to adding one.
Would turbo:before-stream-render
also work in a case where an element is removed, eg to show a fadeout animation?
Iâve built a couple of stimulus controllers that should help with that:
https://www.npmjs.com/package/stimulus-existence and https://www.npmjs.com/package/stimulus-reveal
Existence fires an event when the element lands on the page, which reveal can hook into to show itself. I use this for things like sliding modals in to the page.
I read the part of the recent changelog about how to pause rendering with interest, because Iâve wanted to fade out and fade in on Turbo visits for a long time (I almost implemented Swup.js instead because it was easier to add animations), but I didnât want to add an artificial pause before fetching. Hereâs the relevant part of the handbook:
document.addEventListener('turbo:before-render', async (event) => {
event.preventDefault()
await animateOut()
event.detail.resume()
})
But the example only works if you know how to write an async animation function, which (being a javascript novice (especially when it comes to new syntax)) I donât really. So after trying to get it to work for a long time, I finally tried to hinge the event.detail.resume()
bit on the resolution of a Promise and use the web animations API instead of CSS, and it worked.
But is the code any good? Can it be simplified? (I tried to use await alone and call an async function, but I couldnât get it to work; seems it starts rendering before the animation is finished.)
Here it is, please donât yell at me.
/* entrypoint.scss */
.main {
opacity: 0;
}
.main.visible {
opacity: 1
}
/* entrypoint.js */
// Animation functions
function animateIn(el) {
el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 250 });
el.classList.add("visible");
}
async function animateOut(el) {
el.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 250 });
el.classList.remove("visible");
}
// Run this before render, but after click.
// Use await for the function, then a promise to ensure it's finished before resume
document.addEventListener("turbo:before-render", async (event) => {
const main = document.querySelector(".main");
event.preventDefault();
await animateOut(main);
Promise.all(
main.getAnimations().map((animation) => {
return animation.finished;
})
).then(() => {
event.detail.resume();
});
});
// Run this on every load.
document.addEventListener("turbo:load", () => {
const main = document.querySelector(".main");
animateIn(main);
});
I do CSS based animations really simply -
This looks nice. Iâm trying to do something a bit more complicated â basically, fade out the current element and fade in the replacement.
So I suppose Iâd have an .effect-fadein class and an .effect-fadeout. And then, I guess swap classes somehow instead of remove?
Here you go - this made me smile -
<div id="item_1" class="after-effect-flash effect-flash" data-after-effect="#item_2">HELLO</div>
<div id="item_2" class="after-effect-flash" data-after-effect="#item_1">GOODBYE</div>
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { url: String }
connect() {
window.debug("Animations Controller Connected")
this.removeEffect = this.removeEffect.bind(this)
this.element.addEventListener("transitionend", this.removeEffect)
this.element.addEventListener("animationend", this.removeEffect)
}
disconnect() {
window.debug("Animations Controller Disconnected")
this.element.removeEventListener("transitionend", this.removeEffect)
this.element.removeEventListener("animationend", this.removeEffect)
}
removeEffect() {
var this_id = event.target.id
if (event.target.classList.contains("effect-remove")) {
event.target.remove()
} else {
[...event.target.classList].reverse().forEach( (klass) => {
if (klass.match(/^effect-/)) event.target.classList.remove(klass)
})
}
if (this_id) {
this.element.querySelectorAll(`[data-after-effect="#${this_id}"]`).forEach( (element) => {
[...element.classList].reverse().forEach( (klass) => {
if (klass.match(/^after-effect-/))
element.classList.add(klass.substring(6))
})
})
}
}
}
Awesome! I havenât had a chance to play with it, but Iâll check it out when I can!
Getting off of using Swup, so I looked into adding page transitions on the <main>
element to a Turbo config and ended up using the visit and load events. bridgetown/turbo_transitions.js at 13a9c4faf4df8efa1b1a00bd08929e32f66e5f8b ¡ bridgetownrb/bridgetown ¡ GitHub
The only drawback with this approach is if a request takes a long time, youâll be looking at an empty main part of the page, but itâd be easy enough to overlay some kind of spinner or loading skeleton after a couple seconds if need be. I also added the ability to turn on/off animations from page to page by using a data attribute.
Feel pretty slick to me now.
P. S. Thanks to @evenreven for some initial code to try out.
P. P. S. I sort of think in the world of Turbo a slow route is an anti-pattern anywayâŚyouâd probably want to return a basic page layout immediately and then use frames/streams to pull in âslowâ fragments as needed.
Cool to see Bridgetown going down this route, thanks for sharing, @jaredcwhite.
Any specific reason why you donât use
turbo:before-render like I do above? It solves the specific issue you mention with an empty main element while waiting.
Sure! Yeah in my case I prefer the animation starting immediately on the user interaction. Iâm assuming a scenario where most responses are pretty fast, so itâs better to optimize for an immediate transition start, rather than wait for a response to come back before starting the transition. Itâs how Swup workedâŚbeyond the web, itâs also how mobile apps on iOS typically work: the UI interaction always starts immediately, and then perhaps you see a loading screen for a moment before new data arrives.
The async/await syntax is an alternative way to deal with promises. Rather than writing promise.then(value => âŚ)
, await promise
returns the value of the resolved promise. For example, both run1
and run2
are equivalent:
function calculateMeaningOfLife () {
return new Promise(function (resolve) {
setTimeout(function () { resolve(42) }, 1000)
})
}
function run1 () {
let meaningOfLife
return calculateMeaningOfLife().then(function (meaning) {
meaningOfLife = meaning
})
}
async function run2 () {
let meaningOfLife = await calculateMeaningOfLife()
}
Note that when you use await
you must annotate the containing function with async
.
The idea behind the pauseable rendering feature of Turbo, was that youâd animate out on turbo:visit
, pause rendering on turbo:before-render
to wait for those animations to finish, then add enter animations on turbo:render
. An outline of an implementation might look like:
let animateOut
addEventListener('turbo:load', function () {
// animate out by adding class names
main.classList.add('animate-out')
// create a promise that resolves when the animations have finished
animateOut = Promise.all([âŚ])
})
addEventListener('turbo:before-render', async function (event) {
// pause rendering to wait for animate-out promise to finish
event.preventDefault()
await animateOut
// resume rendering when promise resolves
event.detail.resume()
})
let animateIn
addEventListener('turbo:render', function () {
// animate in by adding class names
main.classList.add('animate-in')
// create a promise that resolves when the animations have finished
animateIn = Promise.all([âŚ])
})
addEventListener('turbo:load', async function () {
// wait for animate-in before remove class names
await animateIn
main.classList.remove('animate-in')
})
This was the approach I took in GitHub - domchristie/turn: đ A starting point for animating page transitions in Turbo apps. I hope that helps!
Thanks for chiming in, @domchristie ! Turn looks very interesting, and an improvement on what Iâm doing. My method is working but it always felt a bit hacky. (Partially because promises make my head spin.) This looks so much better. Looking forward to improving my code later with yours as a guide.
BTW, if anyoneâs already using GSAP, the custom animation chaining syntax GSAP uses makes this a breeze. Iâm not using it on this project, hence my awkward solution above, but using the gsap onComplete event to resume is a lot easier than promises (IMHO). Itâs a bit overkill for only turbo transitions, though, but if youâre already using it, I think itâs probably a good idea to use it for this too.
Doesnât that delay the fetch and, therefore, delaying the page load?
Yes, promises can be a bit of a headspin, but they can be really useful, particularly when trying to run some code after a number of events complete, e.g. when a number of animations have finished.