Tutorial
Adding Modals to Phoenix 1.8 with DaisyUI
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 portaltarget— 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:
close— the browser’s native dialog close event, fired when the user presses ESC or clicks the backdrop. We checkdata-closeableto decide if we should allow it, then run theon_cancelJS command (which might do apush_patchorpush_navigate).
close-dialog— a custom event dispatched by LiveView’sJS.dispatch("close-dialog")inphx-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.