Hotwire Discussion

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!