An AI's interpretation of a house in the Hamptons

Improve UX with LiveView page transitions

Mike Buhot profile picture

Mike Buhot

25 November 2022 · 6 min read

Phoenix LiveView is a web application framework that lets us produce server-rendered HTML and just a little JavaScript to create applications that feel responsive, provide fast feedback to users and can react to changes occurring on the server.

In this post, we’ll touch on LiveNavigation, latency and how to mask it with page transitions during loading. We’ll also have some fun with transitions for adding and removing table rows.

The code shown in this post is based on the output of the Phoenix generators mix phx.new and mix phx.gen.live prior to the release of v1.7.0 built from commit (6811f0ea). By the time Phoenix 1.7 is officially released, there may be some changes to the output of the generators.

Live Navigation

LiveView provides multiple options for navigating between pages, depending on whether it is being initiated from the client or server, and how much of the page needs to be re-loaded:

  • A full page load can be initiated from client side with a <.link href={...}> component, or from the server with the redirect/2 function.
  • A new LiveView can be mounted within the existing page layout from the client with <.link navigate={...}> or JS.navigate/1 or from the server with the push_navigate/2 function.
  • The existing LiveView can be updated without re-mounting from the client with <.link patch={...}/> or JS.patch/1 or from the server with the push_patch/2 function.

See the LiveNavigation guide or Understanding LiveView Navigation for more information.

Latency

Applications that primarily use the <.link navigate={...}/> method for navigation have a fairly smooth user experience, particularly when latency is low and the client can update the UI almost immediately. However, the user experience degrades as the distance between the user and the LiveView server increases, causing noticeable latency.

In a demo application deployed to the us-west-2 AWS region, the latency for navigating between LiveViews experienced by a user in east coast Australia was measured at ~500ms. With a 500ms latency, the page no longer feels like it is updating immediately, instead it becomes obvious that loading is occurring, with the loading indicator visible at the top of the page, before the new content finally pops into view.

We can simulate this experience in the local development environment with the enableLatencySim method on the liveSocket object directly in the browser console window:

> liveSocket.enableLatencySim(500)

Modal Transitions

Notice that the UI is less abruptly updated when showing and hiding the modal form:

Let’s take a closer look at how the modal is implemented to see if we can apply the same idea when navigating between pages. Here’s the outline of the markup generated by mix phx.new for the modal:

def modal(assigns) do
  ~H"""
  <div id={@id} phx-mounted={@show && show_modal(@id)} class="relative z-50 hidden">
    <div id={"#{@id}-bg"} class="fixed inset-0 bg-zinc-50/90 transition-opacity" aria-hidden="true" />
    <div
      class="fixed inset-0 overflow-y-auto"
      aria-labelledby={"#{@id}-title"}
      aria-describedby={"#{@id}-description"}
      role="dialog"
      aria-modal="true"
      tabindex="0"
    >
      <div class="flex min-h-full items-center justify-center">...
      </div>
    </div>
  </div>
  """
end

The outer <div> has the hidden tailwindcss utility class applied, which prevents the modal from being shown initially. The phx-mounted binding invokes the JS command generated by show_modal(@id) to make the modal visible once the page is loaded and the LiveView web socket connected.

The show_modal function uses JS.show to show the modal and fade in the background with a transition.

def show_modal(js \\ %JS{}, id) when is_binary(id) do
    js
    |> JS.show(to: "##{id}")
    |> JS.show(
        to: "##{id}-bg",
        transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"}
    )
    |> show("##{id}-container")
    |> JS.focus_first(to: "##{id}-content")
end

Another helper function show/1 generated by mix phx.new is used from within show_modal:

def show(js \\ %JS{}, selector) do
  JS.show(js,
    to: selector,
    transition:
      {"transition-all transform ease-out duration-300",
       "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
       "opacity-100 translate-y-0 sm:scale-100"}
  )
end

The tailwind css documentation describes the effect of each class.

The general strategy is to initially render the elements as hidden or in some other initial state, then use phx-mounted to show the element and modify the classes applied.

TailwindCSS transition transform duration translate scale and opacity utility classes are used to define the transition.

Fade in on mount

The first transition we’ll apply is a fade-in as the page loads. This should slightly reduce the pop-in effect you experience after waiting for the loading to complete.

We’ll make an edit to the layout template in lib/<app_name>_web/components/layouts/app.html.heex

// layouts/app.html.heex
<main class="px-4 py-20 sm:px-6 lg:px-8 transition-all duration-500 opacity-0" 
      phx-mounted={JS.remove_class("opacity-0")}>

The initial render will include the tailwind css classes transition-all duration-500 opacity-0 on the <main> element containing the LiveView content. Once the <main> element is mounted and the web socket is connected, the phx-mounted binding will execute the JS.remove_class command to remove the "opacity-0" class, allowing the content to fade in:

Fade out on load

The next change is to detect when a new page is being loaded, and fade out the current content.

LiveView dispatches custom window events when page loading starts and finishes. These are used in app.js to show and hide the loading bar:

// Show progress bar on live navigation and form submits
topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" })
window.addEventListener("phx:page-loading-start", info => topbar.delayedShow(200))
window.addEventListener("phx:page-loading-stop", info => topbar.hide())

We can use the same events to add and remove a class from the main element during page loads:

// loading transitions
window.addEventListener("phx:page-loading-start", info => {
  if (info.detail.kind == "redirect") {
    const main = document.querySelector("main");
    main.classList.add("phx-page-loading")
  }
})

window.addEventListener("phx:page-loading-stop", info => {
  const main = document.querySelector("main");
  main.classList.remove("phx-page-loading")
})

The phx-page-loading class is only applied when the event detail.kind property is " redirect" which indicates that a <.link navigate={}/> is being used to navigate to another page. This prevents the fade-out from being applied on simple live_patch updates, such as when closing a modal.

The phx-page-loading class will be defined by a custom tailwindcss variant, allowing any utility classes to be conditionally applied while the page is loading.

The generated tailwind.config.js file already contains several custom variants used by LiveView to apply loading states to forms and buttons, we can add one more for phx-page-loading

plugins: [
    require("@tailwindcss/forms"),
    plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])),
    plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])),
    plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])),
    plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])),
    plugin(({addVariant}) => addVariant("phx-page-loading", [".phx-page-loading&", ".phx-page-loading &"]))
  ]
}

The syntax used to define the variant to allow it to be used on any element with the phx-page-loading class, or any element that is a descendant of one with the phx-page-loading class.

Let’s use phx-page-loading:opacity-0 to fade out while the page is loading

<main
  class="px-4 py-20 sm:px-6 lg:px-8 transition-all duration-500 opacity-0 phx-page-loading:opacity-0"
  phx-mounted={JS.remove_class("opacity-0")}
>

Now the page will fade out while loading, and fade back in once the new content is ready:

Adding and Removing Table Rows

Let’s have some fun with the table included in the generated Index LiveView.

The table component provides an :action slot used for the Edit and Delete links. In the phx-click binding for the Delete action, we JS.push a "delete" event to the LiveView, and apply some transition classes in a helper function fade_away_left

<:action :let={listing}>
  <.link
    phx-click={
      JS.push("delete", value: %{id: listing.id})
      |> fade_away_left("#listing-#{listing.id}")
    }
    data-confirm="Are you sure?"
  >
    Delete
  </.link>
</:action>

Our fade_away_left helper will use the JS.transition function and some TailwindCSS transform classes to make it feel like the deleted item is sliding out of the table.

def fade_away_left(js \\ %JS{}, selector) do
  JS.transition(
    js,
    {"transition-all transform ease-in duration-300", "motion-safe:-translate-x-96 opacity-0", "hidden"},
    to: selector,
    time: 300
  )
end

We can make new table rows slide in from the right, and smoothly scroll into view with some more transition classes and a Phoenix Hook:

<tr
  :for={row <- @rows}
  id={"#{@id}-#{Phoenix.Param.to_param(row)}"}
  class={[
    "group hover:bg-zinc-50",
    if(@new_row == Phoenix.Param.to_param(row),
      do: "transition-all transform duration-500 motion-safe:translate-x-96"
    )
  ]}
  phx-mounted={
    if(@new_row == Phoenix.Param.to_param(row), 
      do: JS.remove_class("motion-safe:translate-x-96"))
  }
  phx-hook={if(@new_row == Phoenix.Param.to_param(row), do: "ScrollIntoView")}
>

A new assign @new_row is added to conditionally add the transition classes to the <tr> element for the newly added row. Once mounted, the motion-safe:translate-x-96 class is removed sliding the new row in from the right.

A hook is used to scroll the new row into view after a short delay when the table becomes large:

// app.js

let Hooks = {
  ScrollIntoView: {
    mounted() {
      setTimeout(() => this.el.scrollIntoView({behavior: "smooth"}), 500)
    }
  }
}
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, { params: { _csrf_token: csrfToken }, hooks: Hooks })

Accessibility and prefers-reduced-motion

It’s important to respect the users preference for reduced motion by prefixing the any transform classes with the motion-safe: modifier.

Testing the app with reduced motion enabled in the system preferences confirms that we no longer have elements sliding across the page:

Reduce motion setting on MacOS

See the TailwindCSS docs, MDN and webkit blog for more details.

Wrapping Up

I hope you find the techniques shown in this post useful to smooth out the navigation in your LiveView projects and liven things even further with some transitions.

Mike Buhot profile picture

WRITTEN BY

Mike Buhot

Hi, I’m Mike! I'm a functional programming enthusiast. When I'm not building awesome software I'm probably enjoying the outdoors with my kids or trying a new low-and-slow barbeque recipe.

Customers providing feedback on a product

Strategy & Advisory

Do your tech choices empower a test and learn cycle?

Ben Melbourne profile picture

Ben Melbourne

1 February 2024 – 2 min read

Software developers using a development framework

Ash

Simplify Your Web Development: Discover the Power of Ash Framework

Ben Melbourne profile picture

Ben Melbourne

3 May 2024 – 4 min read

Want to read more?

The latest news, articles, and resources, sent to your inbox occasionally.