Tutorial

Adding Modals to Phoenix 1.8 with DaisyUI

phoenix modal liveview daisyui tailwind

In Phoenix 1.8, the built-in modal component was removed. Instead, Phoenix now encourages developers to use separate LiveView pages for new and edit actions. However, modals are still extremely useful for many UI interactions, and sometimes you want to keep users on the same page while they complete an action.

In this tutorial, we’ll create a modal component that leverages the native HTML <dialog> element styled with DaisyUI. We’ll build this for our Tutorial application and make it easy to reuse throughout the app.

Step 1: Create the Modal Component Module

First, let’s create a new file for our modals component. We’ll place it in lib/tutorial_web/components/modals.ex:

defmodule TutorialWeb.Components.Modals do
@moduledoc """
Shared modal components.
"""
use Phoenix.Component
use Gettext, backend: TutorialWeb.Gettext
alias Phoenix.LiveView.JS
attr :id, :string, required: true
attr :show, :boolean, default: false
attr :on_cancel, JS, default: %JS{}
attr :closeable, :boolean, default: true
attr :max_width, :string, default: "md:max-w-3xl"
slot :inner_block, required: true
slot :title
def modal(assigns) do
~H"""
<.portal id={"modal-portal-#{@id}"} target="#modal-root">
<dialog
:if={@show}
id={@id}
phx-hook="Modal"
phx-mounted={
@show &&
JS.ignore_attributes("open")
|> JS.dispatch("open-dialog", to: "##{@id}")
|> JS.focus_first(to: "##{@id}-container")
}
phx-remove={
JS.dispatch("close-dialog", to: "##{@id}")
|> JS.pop_focus()
|> JS.transition("dummy-class-to-delay-push-navigate",
to: "##{@id}",
time: 300
)
}
data-cancel={@on_cancel}
data-closeable={@closeable && "true"}
class="modal"
>
<div class={["modal-box p-0 border bg-base-200 border-base-300 text-left", @max_width]}>
<form :if={@closeable && @title in [nil, []]} method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<header
:if={@title != []}
class="flex items-center justify-between px-6 py-4 border-b rounded-t border-base-300"
>
<h3 class="text-left text-lg font-semibold text-base-content">
{render_slot(@title)}
</h3>
<form :if={@closeable} method="dialog">
<button class="btn btn-sm btn-circle btn-ghost"></button>
</form>
</header>
<.focus_wrap id={"#{@id}-container"} class="p-6 space-y-6">
{render_slot(@inner_block)}
</.focus_wrap>
</div>
<form method="dialog" class="modal-backdrop backdrop-blur-sm">
<button :if={@closeable}>{gettext("close")}</button>
</form>
</dialog>
</.portal>
"""
end
end

A few things worth noting here. The <dialog> element is a native HTML element that handles a lot of accessibility behaviour out of the box — focus trapping, ESC key handling, and the backdrop. DaisyUI’s modal, modal-box, and modal-backdrop classes take care of the styling.

The phx-hook="Modal" attribute connects our JavaScript hook, which we’ll set up shortly. The phx-mounted and phx-remove attributes use LiveView JS commands to open and close the dialog with a smooth transition.

The :if={@show} attribute means the dialog is only rendered in the DOM when it’s supposed to be visible. When @show becomes false, LiveView removes the element and phx-remove runs the closing animation first.

Step 2: Add the Modal Root to root.html.heex

For the portal to work, it needs a target element in the DOM. Add a #modal-root div at the bottom of your <body> tag in lib/tutorial_web/components/layouts/root.html.heex:

<body class="bg-base-100 antialiased">
{@inner_content}
<div id="modal-root"></div>
</body>

This is where all modals will be teleported to. Why teleport at all? Because modals rendered deep inside a LiveView component tree can run into stacking context issues — a parent element with overflow: hidden or a low z-index can clip or hide your modal. By moving all modals to a top-level container, we sidestep those CSS headaches entirely.

Step 3: Understanding the Portal Component

The <.portal> component is built into Phoenix LiveView. It takes two attributes:

  • id — a unique identifier for the portal
  • target — a CSS selector pointing to where you want the content teleported

Under the hood, it moves the rendered HTML from wherever you called <.portal> in your component tree into the target element. So even if you render <.modal> inside a deeply nested LiveView, the actual <dialog> element ends up as a direct child of #modal-root.

That means you can use it anywhere in your templates without worrying about z-index or overflow issues.

Step 4: Add the JavaScript Hook

The modal hook handles the native <dialog> API and bridges the gap between the browser’s built-in dialog behaviour and LiveView’s JS commands.

Create assets/js/modal.js:

export default {
mounted() {
let closeDueToAction = false
const modal = this.el
setTimeout(() => {
modal.showModal()
}, 50)
modal.addEventListener('close', event => {
// Prevent closing if modal is not closeable
if (!modal.dataset.closeable) {
event.preventDefault()
modal.showModal()
return
}
// Handles the dialog's native close event.
// Triggered when the user clicks outside the modal or presses ESC.
setTimeout(() => {
if (closeDueToAction) return
liveSocket.execJS(modal, modal.dataset.cancel)
closeDueToAction = false
}, 300)
})
modal.addEventListener('close-dialog', event => {
// Avoid running cancel action when closing via LiveView
closeDueToAction = true
modal.close()
})
}
}

There are two events we listen to:

  1. close — the browser’s native dialog close event, fired when the user presses ESC or clicks the backdrop. We check data-closeable to decide if we should allow it, then run the on_cancel JS command (which might do a push_patch or push_navigate).
  1. close-dialog — a custom event dispatched by LiveView’s JS.dispatch("close-dialog") in phx-remove. This tells us the modal is closing because LiveView triggered it (not the user clicking outside), so we shouldn’t run the cancel action again.

The closeDueToAction flag is the trick that prevents double-firing the cancel callback.

Register the hook in assets/js/app.js:

import Modal from "./modal"
let Hooks = {}
Hooks.Modal = Modal

Step 5: Import the Component

To make <.modal> available across your LiveViews, import it in your tutorial_web.ex file inside the html_helpers block:

defp html_helpers do
quote do
# ... existing imports
import TutorialWeb.Components.Modals
end
end

Step 6: Using the Modal

Now you can use the modal in any LiveView template. The simplest way is to control it with a @show assign:

# lib/tutorial_web/live/demo_live.ex
def mount(_params, _session, socket) do
{:ok, assign(socket, show_modal: false)}
end
def handle_event("open_modal", _, socket) do
{:noreply, assign(socket, show_modal: true)}
end
def handle_event("close_modal", _, socket) do
{:noreply, assign(socket, show_modal: false)}
end

And in the template:

<button phx-click="open_modal" class="btn btn-primary">
Open Modal
</button>
<.modal id="demo-modal" show={@show_modal} on_cancel={JS.push("close_modal")}>
<:title>Hello from the modal</:title>
<p>This is the modal content. You can put anything in here.</p>
<div class="flex justify-end">
<button phx-click="close_modal" class="btn btn-primary">Done</button>
</div>
</.modal>

The on_cancel attribute is what runs when the user closes the modal via ESC or clicking the backdrop. Here we push a "close_modal" event back to the LiveView so it can update @show_modal to false.

Step 7: Modal Without a Title

If you don’t pass a <:title> slot, the modal renders a small close button in the top-right corner instead of a header:

<.modal id="simple-modal" show={@show_modal} on_cancel={JS.push("close_modal")}>
<p>A simple modal without a header.</p>
</.modal>

Step 8: Non-Closeable Modal

Sometimes you want to force the user to take an action before they can dismiss the modal — like confirming a destructive operation. Pass closeable={false}:

<.modal id="confirm-modal" show={@show_modal} closeable={false}>
<:title>Are you sure?</:title>
<p>This action cannot be undone.</p>
<div class="flex justify-end gap-2">
<button phx-click="cancel" class="btn">Cancel</button>
<button phx-click="confirm_delete" class="btn btn-error">Delete</button>
</div>
</.modal>

With closeable={false}, clicking the backdrop or pressing ESC won’t close the modal. The user has to click one of the buttons.

Step 9: Controlling Width

The max_width attribute lets you control the width of the modal box. It defaults to "md:max-w-3xl" but you can pass any Tailwind max-width class:

<.modal id="wide-modal" show={@show_modal} max_width="md:max-w-5xl" on_cancel={JS.push("close_modal")}>
<:title>A wider modal</:title>
<p>More room for content.</p>
</.modal>

Wrapping Up

The combination of the native <dialog> element, DaisyUI’s modal classes, and LiveView’s JS commands gives us a solid modal setup that:

  • Handles accessibility out of the box (focus trapping, ESC key)
  • Renders outside the component tree via the portal, avoiding CSS stacking issues
  • Supports optional titles, close buttons, and width customisation
  • Plays nicely with LiveView’s diff patching

It’s a good bit simpler than the Alpine.js approach I covered in the old modal tutorial, and you get proper accessibility behaviour for free with the native dialog element.

Related Tutorials

Published 04 May - 2021
Updated 27 Mar

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 11 Jul - 2020
Updated 27 Mar

Create a reusable modal with LiveView Component

To reduce duplicity and complexity in your apps, Phoenix LiveView comes with the possibility to use reusable components. Each component can have its..