Tutorial

Create a reusable modal with LiveView Component

This post was updated 22 May - 2021

phoenix modal liveview tailwind alpinejs

To reduce duplicity and complexity in your apps, Phoenix LiveView comes with the possibility to use reusable components. Each component can have its own state and event handling so all logic doesn't have to live in the parent LiveView.

With that in mind, one thing I quite often use in web apps are modals. However, they require a fair amount of markup and since I dont want to repeat markup in all the instances I use it, I would like to put it in a reusable component. In LiveView that is called a LiveComponent.

In this tutorial I will go through how to set up reusable modal in Phoenix LiveView with AlpineJS and Tailwind.

Modern modals also often comes with a nice transition so its a little tricky to set up, but AlpineJS provides nice helpers to add classes to the transitions.

STEP 1 - Setup a minimal example of a LiveComponent

A LiveView component can be used to wrap content instead of passing it in as an argument. One caveat is that it needs to have its unique DOM id.

In the LiveView it can be used like:

<%= live_component @socket, MyComponent, id: "component-1" do %>
  <p>Content</p>
<% end %>

And the component can look like:

defmodule MyComponent do
  use Phoenix.LiveComponent  def mount(socket) do
    {:ok, socket}
  end  def render(assigns) do
    ~L"""
    <div id="<%= @id %>">
      <%= render_block(@inner_block, []) %>
    </div>
    """
  end
end

The result of that would be a DOM node like:

<div id="component-1">
  <p>Content</p>
</div>

With that in mind, I hope you can see that this has some potential.

STEP 2 - Set the stage

First of all I want to make clear on what I want to achieve

  1. I modal where the markup is reusable
  2. Needs be able to be opened and be closed from LiveView
  3. Should have some nice design and CSS transitions

Note I have already installed Alpine.js in this tutorial

I want to make sure this works with two different modal so first I want to start with the two buttons:

<!-- lib/tutorial_web/live/demo_live.html.leex -->
<div>
  <button
    phx-click="open"
    phx-target="#modal-one"
    type="button"
    class="mr-8 btn btn-primary">
    Open modal one
  </button>  <button
    phx-click="open"
    phx-target="#modal-two"
    type="button"
    class="btn btn-primary">
    Open modal two
  </button>
</div>

With the markup for the two buttons in place they shuld look something like this:

These two buttons should trigger LiveView events with the information about which modal to open. Those html attributes looks like. The LiveView javascript handles the click events and make sure they get handled by the correct components.

phx-click="open" phx-target="#modal-one"

Note remember that each component require its unique DOM id in the template code for the component.

In the LiveView template I will use the modals like this, and the idea is that the content for the modals will be inside the do/end.

<!-- lib/tutorial_web/live/demo_live.html.leex -->
<%= live_component @socket, TutorialWeb.Components.Modal, id: "modal-one" do %><% end %><%= live_component @socket, TutorialWeb.Components.Modal, id: "modal-two" do %><% end %>

Each of these live components, will render the same template but with differendet variables passed in.

Just to get the first iteration of this started, the markup for the soon to be modal looks like this. It holds a simple if/else statement to show that each component can hold its own state.

<!-- lib/tutorial_web/live/components/modal.html.leex -->
<div id="<%= @id %>">
  <%= if @state == "OPEN" do %>
    OPEN
  <% else %>
    CLOSED
  <% end %>
</div>

The state can me OPEN or CLOSED and it starts as CLOSED. As soon at a button is clicked, the handle_event open is being triggered. The idea is that the modal will open then.

# lib/tutorial_web/live/components/modal.ex
defmodule TutorialWeb.Components.Modal do
  use TutorialWeb, :live_component  @impl true
  def mount(socket) do
    {:ok, assign(socket, state: "CLOSED")}
  end  @impl true
  def update(assigns, socket) do
    {:ok,
      socket
      |> assign(assigns)
    }
  end  @impl true
  def handle<i>event("open", </i>, socket) do
    {:noreply, assign(socket, :state, "OPEN")}
  end  def handle_event("close", _, socket) do
    {:noreply, assign(socket, :state, "CLOSED")}
  end
end

This is all the state-logic the modal component needs to have. This won't change in this tutorial.

So when the buttons are clicked, the state changed from CLOSED to OPEN.

This completed the first iteration on this.

STEP 3 - Render content with a variable

As I mentioned above, the component supports passing in variables, like the id, but also have a block of content. With the updated syntax it looks like this. The @local_state variable is set from the component itself. And as above, it will be CLOSED as default.

<!-- lib/tutorial_web/live/demo_live.html.leex -->
<%= live_component @socket, TutorialWeb.Components.Modal, id: "modal-one" do %>
  MODAL ONE <%= @local_state %>
<% end %><%= live_component @socket, TutorialWeb.Components.Modal, id: "modal-two" do %>
 MODAL TWO <%= @local_state %>
<% end %>

I have removed the old code in the template and replaced id with render_block

<!-- lib/tutorial_web/live/components/modal.html.leex -->
<div id="<%= @id %>">
  <%= render_block(@inner_block, local_state: @state) %>
</div>

I kan now interact with the components on the same way again.

Ok, now I hope that you can start to see the potential with this and follow the logic. Next step is to create the actual modal.

STEP 4 - Make tha actual modal

The markup for the modal is pretty verbose. Note that there are also some AlpineJS code here that will help with the interaction with the component. Basically, AlpineJS also has its own state and starts with { open: false }.

<!-- lib/tutorial_web/live/components/modal.html.leex -->
<div id="<%= @id %>">
  <%= if @state == "OPEN" do %>
  <div
    id="modal-<%= @id %>"
    phx-hook="InitModal"
    phx-target="#<%= @id %>"
    x-data="{ open: false }"
    x-init="() => {
      setTimeout(() => open = true, 100);
      $watch('open', isOpen => $dispatch('modal-change', { open: isOpen, id: '#<%= @id %>' }))
    }"
    x-on:close-now="open = false"
    x-show="open"
    class="fixed inset-x-0 bottom-0 z-50 px-4 pb-4 sm:inset-0"
    >    <!-- BACKDROP -->
    <div
      x-show="open"
      x-transition:enter="ease-out duration-300"
      x-transition:enter-start="opacity-0"
      x-transition:enter-end="opacity-100"
      x-transition:leave="ease-in duration-200"
      x-transition:leave-start="opacity-100"
      x-transition:leave-end="opacity-0"
      class="fixed inset-0 transition-opacity"
    >
      <div class="absolute inset-0 bg-gray-900 opacity-50"></div>
    </div>    <!-- MODAL DIALOG -->
    <div
      x-show="open"
      x-transition:enter="ease-out duration-300"
      x-transition:enter-start="opacity-0 mb-2 sm:mb-8 sm:mt-2 sm:scale-95"
      x-transition:enter-end="opacity-100 mb-8 sm:mt-8 sm:scale-100"
      x-transition:leave="ease-in duration-200"
      x-transition:leave-start="opacity-100  mb-8 sm:mt-8  sm:scale-100"
      x-transition:leave-end="opacity-0  mb-2 sm:mb-8 sm:mt-2  sm:scale-95"
      class="relative w-full max-w-lg px-4 mx-auto my-8 shadow-lg sm:px-0">      <div @click.away="open = false" @keydown.escape.window="open = false" class="relative flex flex-col bg-white border border-gray-200 rounded-lg">
        <!-- MODAL HEADER -->
        <div class="flex items-center justify-between p-4 border-b border-gray-200 rounded-t">
          <h5 class="mb-0 text-base font-semibold text-gray-500 uppercase"><%= assigns[:title] %></h5>
          <button type="button" @click="open = false" class="text-gray-400 hover:text-gray-500 focus:outline-none focus:text-gray-500 transition ease-in-out duration-150">
            &times;
          </button>
        </div>
        <!-- MODAL BODY -->
        <div class="relative flex-auto p-4">
          <%= render_block(@inner_block, modal_id: @id) %>
        </div>
      </div>
    </div>
  </div>
  <% end %>
</div>

To make this fully interactive, and with the possibility to comunicate with LiveView, I need to add a LiveView hook. phx-hook="InitModal" That modal javascript hook look like this:

export const InitModal = {
  mounted() {
    const handleOpenCloseEvent = event => {
      if (event.detail.open === false) {
        this.el.removeEventListener("modal-change", handleOpenCloseEvent)        setTimeout(() => {
          // This timeout gives time for the animation to complete
          this.pushEventTo(event.detail.id, "close", {})
        }, 300);
      }
    }    // This listens to modal event from AlpineJs
    this.el.addEventListener("modal-change", handleOpenCloseEvent)    // This is the close event that comes from the LiveView
    this.handleEvent('close', data => {
      if (!document.getElementById(data.id)) return      const event = new CustomEvent('close-now')
      this.el.dispatchEvent(event)
    })
  }
}

The javascript hook needs to be added to the LiveView in the app.js file like:

// assets/js/app.js
import {InitModal} from "./init_modal"
Hooks.InitModal = InitModal

In the template, I have now made it possible to add a title variable as well. The only thing needed for the modal body is the MODAL ONE and MODAL TWO texts:

<!-- lib/tutorial_web/live/demo_live.html.leex -->
<%= live_component @socket, TutorialWeb.Components.Modal, id: "modal-one", title: "Modal one title" do %>
  MODAL ONE
<% end %><%= live_component @socket, TutorialWeb.Components.Modal, id: "modal-two", title: "Modal two title" do %>
 MODAL TWO
<% end %>

As you can see below, everything should be working.

Basically, the modal is done now. However, there is still one thing missing. The possibility to interact with the modal from an external source like the LiveView.

STEP 5 - Close modal from LiveView

Last thing I want to go through, is to close the modal from the LiveView. This is useful if the modal has a form that is submitted and after submit, the modal should close. However, for now, I will just illustrate this with an event from a button click.

<%= live_component @socket, TutorialWeb.Components.Modal, id: "modal-one", title: "Modal one title" do %>
  <button
    phx-click="open"
    phx-value-modal-id="<%= @modal_id %>"
    type="button"
    class="btn btn-primary">
    Close
  </button>
<% end %>

So, inside the modal body, I add a button with a LiveView click handler. When I open the modal, it looka like this:

Since this doesnt have a phx-target, the parent LiveView and not the component will revive the click event. The code for that looks like this:

@impl true
def handle_event("close-modal", %{"modal-id" => id}, socket) do
  {
    :noreply,
    socket
    |> push_event("close", %{id: id})
  }
end

NOTE that the push_event is handled by the javascript hook. and to be sure its the correct hook, I also pass along the id so the javascript hook kan send the event to the correct AlpineJs component.

With this, I have a final solution that I am quite happy with. A reusable modal with build with Phoenix LiveView, AlpineJS and Tailwind CSS.

Related Tutorials

Published 04 May - 2021
Updated 05 May - 2022

How to combine Phoenix LiveView with Alpine.js

No matter how great Phoenix LiveView is, there is still some use case for sprinking some JS in your app to improve UX. For example, tabs, dropdowns,..

Published 13 Feb - 2020
Updated 01 May - 2020

Tagging interface with Phoenix LiveView and Tailwind - Tagging part 2

In the previous tutorial, I set up the the backend for being able to add tags to products. I have also written a tutorial about adding a LiveView an..