Psst. It would be super cool if you could try the new Phoenix Boilerplate

Try now →

Tutorial

View on Github

Create a reusable modal with LiveView Component

alpinejsliveviewmodalphoenixtailwind

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.

Modern modals also often comes with a nice transition so its a little tricky to set up. I tried to do it with pure Phoenix LiveView and Tailwind but the code was too convoluted brittle where I needed to invent a lot of states.

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

STEP 1 - A minimal example

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 your 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 %>">
      <%= @inner_content.([]) %>
    </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/modals_live.html.leex -->
<div class="flex">
  <span class="rounded-md shadow-sm">
    <button phx-click="open-modal" phx-value-id="modal-one" type="button" class="inline-flex justify-center w-full rounded-md border border-gray-300 px-4 py-2 bg-white text-sm leading-5 font-medium text-gray-700 hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800 transition ease-in-out duration-150">
      Open modal one
    </button>
  </span>
  <span class="ml-4 rounded-md shadow-sm">
    <button phx-click="open-modal" phx-value-id="modal-two" type="button" class="inline-flex justify-center w-full rounded-md border border-gray-300 px-4 py-2 bg-white text-sm leading-5 font-medium text-gray-700 hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800 transition ease-in-out duration-150">
      Open modal two
    </button>
  </span>
</div>

These two buttons should trigger LiveView events with the information about which modal to open.

phx-click="open-modal" phx-value-id="modal-one"

Note remember that each component require its unique DOM id.

In the LiveView template I will use the modals like:

<%= live_component @socket, TutorialWeb.ModalComponent, id: "modal-one" do %>
 ...
<% end %>

<%= live_component @socket, TutorialWeb.ModalComponent, id: "modal-two" do %>
  ...
<% end %>

To trigger the events I need to add the handle_event

defmodule TutorialWeb.ModalsLive do
  use Phoenix.LiveView

  def handle_event("open-modal", %{"id" => id}, socket) do
    send_update TutorialWeb.ModalComponent, id: id, state: "OPEN"
    {:noreply, socket}
  end
end

So, here I am instructing the component with the specific id to update state to OPEN

So far so good. I will just paste in the entire component here.

NOTE that the markup has both phx- elements and Alpine element.

  1. When state is CLOSED I wont render anything but an empty div
  2. When state OPEN the model is rendered but in Alpine world the modal is not yet opened.
  3. There is an x-init callback with a setTimeout(() => open = true, 100). Basically needed for the css animation to work.
  4. There is a LiveView hook with phx-hook="initModal". The idea is to tell LiveView that the modal has closed using Alpine.js
  5. In x-init there is also $watch('open', isOpen => $dispatch('modal-change', { open: isOpen })). I want to dispatch a js event when Alpine closes the modal so LiveView can remove it from the DOM.
defmodule TutorialWeb.ModalComponent do
  use Phoenix.LiveComponent

  def mount(socket) do
    {:ok, assign(socket, state: "CLOSED", action: nil)}
  end

  def render(assigns) do
    if assigns.state == "OPEN" do
      ~L"""
      <div
        id="<%= @id %>"
        phx-hook="initModal"
        x-data="{ open: false }"
        x-init="() => {
          setTimeout(() => open = true, 100);
          $watch('open', isOpen => $dispatch('modal-change', { open: isOpen }))
        }"
        x-show="open"
        @close-modal="setTimeout(() => open = false, 100)"
        class="z-50 fixed bottom-0 inset-x-0 px-4 pb-4 sm:inset-0 sm:flex sm:items-center sm:justify-center"
      >
        <%= if @action == "CLOSE" do %>
          <div id="close-modal-<%= @id %>" data-modal-id="<%= @id %>" phx-hook="closeModal"></div>
        <% end %>

        <!-- 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-800 opacity-75"></div>
        </div>

        <div x-show="open" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100" x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100" x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" class="bg-white rounded-lg overflow-hidden shadow-xl transform transition-all sm:max-w-lg sm:w-full">
          <div @click.away="open = false" class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
            <div class="hidden sm:block absolute top-0 right-0 pt-4 pr-4">
              <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">
                <svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
                  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
                </svg>
              </button>
            </div>
            <!-- CONTENT -->
            <%= @inner_content.([]) %>
          </div>
        </div>
      </div>
      """
    else
     ~L"""
     <div id="closed-modal-<%= @id %>"></div>
     """
    end
  end
end

The js in the hook looks like:

// app.js
Hooks.initModal = {
  mounted() {
    const handleOpenCloseEvent = event => {
      if (event.detail.open === false) {
        this.el.removeEventListener("modal-change", handleOpenCloseEvent)
        this.pushEvent("close-modal", {id: this.el.id})
      }
    }
    this.el.addEventListener("modal-change", handleOpenCloseEvent)
  }
}

Note the DOM id should be the same as the components id for convenience.

That means that I also need a "close-modal" handler in the LiveView

# lib/tutorial_web/live/modals_live.ex
defmodule TutorialWeb.ModalsLive do
  use Phoenix.LiveView

  ...
  
  def handle_event("close-modal", %{"id" => id}, socket) do
    :timer.sleep(300) # SO THE CSS ANIMATIONS HAVE TIME TO RUN
    send_update TutorialWeb.ModalComponent, id: id, state: "CLOSED", action: nil
    {:noreply, socket}
  end
end

STEP 3 - Close the modal from LiveView

This works great but what if I want to close the modal from LiveView after a form has been validated and submitted. Then I need a little more JS.

Lets pretend I submit a form. Then I need to instruct the modal to close. I call it action: "CLOSE"

def handle_event("submit", %{"id" => id}, socket) do
  send_update TutorialWeb.ModalComponent, id: id, action: "CLOSE"
  {:noreply, socket}
end

In the LiveComponent I have a some code and triggers a new hook with phx-hook="closeModal"

<%= if @action == "CLOSE" do %>
  <div id="close-modal-<%= @id %>" data-modal-id="<%= @id %>" phx-hook="closeModal"></div>
<% end %>

That hook will just send an event called close-modal to an event listener attached to specific DOM

Hooks.closeModal = {
  mounted() {
    const modalId = this.el.dataset.modalId
    const el = document.getElementById(modalId)
    const event = new CustomEvent('close-modal');
    el.dispatchEvent(event)
  }
}

And Alpine handles that with a listener in the root element of the component

<div 
  id="<%= @id %>"
  ...
  @close-modal="setTimeout(() => open = false, 100)"
>

Note I need to have the setTimeout so the animations work and the modal just does`’t disappear. ### Final result To be honest, I think the markup and the solution is a little convoluted. However, its quite powerful and the actual result is exactly what I want. I am quite happy.


Related Tutorials

Published 31 Mar

Combine Phoenix LiveView with Alpine.js

alpinejsliveviewphoenixtailwind

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

Tagging interface with Phoenix LiveView and Tailwind - Tagging part 2

formsliveviewphoenixtaggingtailwindtypeahead

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..