Issue 06

Triggering repeatable animations from the server in LiveView & Elixir

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

Jul 22, 2022 · 7 minute read

Phoenix LiveView

Phoenix LiveView is my favourite 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 acompany todays 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

If you want to hear more about any of these projects, send me a message on Twitter and I'll be happy to dive into more about how they're built.

PETAL stack?

Phoenix, Elixir, Tailwind, Alpine & LiveView.

Why would you want to trigger front-end animations from the backend?

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, 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!

So I made a simple LiveView component

Component Screenshot

  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 access the page, but that's not very real-time is it?

Presently the information is only updated on mount - lets 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; triggering animations from the backend to really delight our readers.

Animation time 🎨

A note on CSS

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.

First attempt

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.

Second attempt

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.

Third attempt

  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!

Fourth attempt

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
  • 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 fires
  • 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 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>

Result

Wiggle

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

We're done! 🚀

We've successfully triggered repeatable front-end animations from live events coming from other users of our application with minimal code (and literally 6 lines of JavaScript).

I love LiveView and I hope this post has given you a flavour of why.

Subscribe to my Substack below for similar content and follow me on Twitter for more LiveView, Elixir, and general programming tutorials & tips.

Enjoyed this content?

Want to learn and master LiveView?

Check out the book I'm writing

The Phoenix LiveView Cookbook
fin

Sign up to my substack to be emailed about new posts