Are transitions and animations on Hotwire roadmap?

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.

3 Likes

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…

turbo_stream_animation

(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.

5 Likes

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.

1 Like

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.

4 Likes

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. :wink:

/* 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 -

2 Likes

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. :sunglasses:

P. S. Thanks to @evenreven for some initial code to try out. :+1:

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!

1 Like

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.