Triggering repeatable animations from the server in LiveView & Elixir

A guide for Elixir developers (and why you might want to)

Photo by Ján Jakub Naništa on Unsplash

Phoenix LiveView is my favorite way to create web applications these days — the PETAL stack is effortlessly fun to use and will (in my opinion) soon be a mainstream stack of choice for web developers looking to create real-time applications without having to worry about the present-day client-side worries that accompany today’s most popular tools of choice.

Chris McCord eloquently describes the power you gain alongside the god-send of being able to practically forget about 90% of the client-side code in his Fly.io blog post of how LiveView came to be — highly recommended reading.

I’ve built a few LiveView applications (and consider myself lucky enough to be able to use it at work) including:

  • 6words.xyz — a wordle inspired web game
  • niceice.io — a SaaS service for capturing feedback from your users as easily as possible
Petal Stack?
Phoenix, Elixir, Tailwind, Alpine & LiveView.

LiveView is great at building real-time applications — when I say real-time I mean instantly reactive across all users currently browsing the site.

I’m currently working on a platform to enable the sharing of user-created fictional stories online when I managed to stumble across the basis of why this tutorial is needed whilst trying to bring a new feature to life.

As part of my new platform users can submit stories, and chapters and produce content for users to read. Wanting to add some more pizzazz to my application — I figured it’d be cool to have a Live Global Statistics component on the front page of my site so users could see how active the site was in real-time!

def mount(_params, _session, socket) do
socket =
socket
|> assign(:story_count, total_story_count())
|> assign(:word_count, total_word_count())
|> assign(:chapter_count, total_chapter_count())
{:ok, socket}
end

def render(assigns) do
~H"""
<p><span id="stories-count"><%= @stories_count %></span> stories submitted</p>
<p><span id="chapters-count"><%= @chapters_count %></span> chapters published</p>
<p><span id="word-count"><%= @word_count %></span> words written</p>
"""
end

So this is cool — I have a component that updates whenever a user accesses the page, but that’s not very real-time is it?

Presently the information is only updated on mount — let’s change that with the magic of the Phoenix.PubSub module that ships with Phoenix by default.

To do so we need to create a topic for our PubSub to subscribe to (and enable PubSub in my applications supervisor tree):

# MyApplication.Submissions  @topic inspect(__MODULE__)  def subscribe do
PubSub.subscribe(MyApplication.PubSub, @topic)
end
defp notify_subscribers({:ok, result}, event) do
PubSub.broadcast(MyApplication.PubSub, @topic, {__MODULE__, event, result})
{:ok, result}
end

I can now use this notify_subscribers/2 function whenever I want to alert something subscribed to an update I’m interested in broadcasting like so:

def update_story(%Story{} = story, attrs) do
story
|> Story.update_changeset(attrs)
|> Repo.update()
|> notify_subscribers([:story, :updated]) # ⬅️ the interesting bit
end

Then we need to ensure that when our live_component mounts and connects to the WebSocket, it subscribes to the topic.

def mount(_params, _session, socket) do
if connected?(socket) do
MyApplication.Submissions.subscribe()
end

# and add an event listener to ensure our LiveView knows to react when it receives a message from our subscribed topic
def handle_info({MyApplication.Submissions, [:story, _], _}, socket) do
socket =
socket
|> assign(:story_count, total_story_count())

{:noreply, socket}
end
end

Now when we update our stories — notice I’m ignoring the second atom so I’ll call my new assignment whenever any story change happens — our front-end will update for all users!

We have an issue though.

There’s no animation! This can be pretty jarring for users so let’s get onto the real point of this post; Trigging animations from the backend to really delight our readers.

For my example, I’m using Tailwind (yay, PETAL 🌸 stack) but this will work with any CSS class so long as the animation and keyframe attributes have been set appropriately.

First, let’s define our animation in CSS (in our tailwind.config.js):

theme: {
extend: {
keyframes: {
wiggle: {
'0%': { transform: 'translateY(0px) scale(1,1)' },
'25%': { transform: 'translateY(-4px) scale(1.05,1.05)', background: 'aquamarine' },
'100%': { transform: 'translateY(0px) scale(1,1)' },
}
},
animation: {
wiggle: 'wiggle 0.5s linear 1 forwards',
}
},
},

All we’re doing is making it jump a little; let’s press on with actually integrating this.

At first, I believed I could simply use the LiveView.JS library to add a class to the element in question from the backend and pass it to the front end like so:

def do_animation do
JS.add_class("animate-wiggle", to: "#word-count")
end

Keep in mind I was also testing this using a simple button with a click handler phx-click={do_animation} for ease of not having to actually trigger backend events each time – so I was using phx-click…

This added the class and the animation did a little jump — great.

I clicked it again and nothing happened, not great.

This is because the class lived on the element so adding it again meant nothing would happen — my animation wasn’t repeatable. Whoops.

Let’s remove the class after the class has been added.

def do_animation do
JS.add_class("animate-wiggle", to: "#word-count")
send(self(), JS.remove_class("animate-wiggle", to: "#word-count"))
end

This didn’t work because the class was being removed as it was being added. I could’ve added a timeout but that seems far too hacky.

def animate_wiggle(element_id) do
JS.transition(%JS{}, "animate-wiggle", to: element_id, time: 500)
end

JS.transition/2 to the rescue! The LiveView team built a specific function for triggering transitions repeatedly.

But there was an issue — LiveView.JS functions simply generate JavaScript, so they have to be rendered in the page!

So what do we do?

RTFM of course! Onwards!

I had to push the event to the browser so that some JavaScript could execute the wiggle animation for me — so the flow goes like this:

  • PubSub broadcasts event
  • Each subscribed LiveView process listens to that event and triggers an event to their clients
  • The client has a JavaScript event listener to pick up on phx events to react to them
  • JavaScript fires a call to the client to trigger the animation
  • JS.transition/2 fire
  • Wiggle wiggle

Let’s add the JS event listener in our App.js:

window.addEventListener(`phx:wiggle`, (e) => {
let el = document.getElementById(e.detail.id)
if(el) {
liveSocket.execJS(el, el.getAttribute("data-wiggle"))
}
})

Let’s update our event handler on when to push the event to the client:

def handle_info({MyApplication.Submissions, [:story, _], _}, socket) do
socket =
socket
|> assign(:story_count, total_story_count())
|> push_event("wiggle", %{id: "stories-count"}) # ⬅️ the new addition

{:noreply, socket}
end

We also need to ensure we add an id and a data attribute to the element we want to wiggle so our JavaScript can find it and know what to do with it:

<p><span id="stories-count" data-wiggle={animate_wiggle("#stories-count")}><%= @stories_count %></span> stories submitted</p>

What you can’t see is I have another window triggering the aforementioned events.

Leave a Comment