We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
Modal
A fully-managed dialog component with accessibility features and smooth transitions. Modals can be loaded asynchronously from the server, integrated with forms, and paired with live navigation for deep linking capabilities.
Body scrolling is automatically prevented when modals are open and focus is trapped within the modal to ensure users stay focused on the modal content.
Quick Start
The most basic modal requires just a trigger button and three components: .modal, .modal_overlay, and .modal_panel.
<.button type="button" phx-click={Prima.Modal.open("demo-modal")}>
Open Modal
</.button>
<.modal id="demo-modal" class="relative z-10">
<.modal_overlay
transition_enter={{"ease-out duration-300", "opacity-0", "opacity-100"}}
transition_leave={{"ease-in duration-200", "opacity-100", "opacity-0"}}
class="fixed inset-0 bg-gray-500/75 transition-opacity"
/>
<div class="fixed inset-0 z-10 w-screen overflow-y-auto">
<div class="flex min-h-full items-end justify-center p-4 sm:items-center sm:p-0">
<.modal_panel
id="demo-modal-panel"
class="relative overflow-hidden rounded-lg bg-white px-4 py-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm"
transition_enter={
{"ease-out duration-300", "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
"opacity-100 translate-y-0 sm:scale-100"}
}
transition_leave={
{"ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
}
>
<div class="relative">
<button
class="absolute top-0 right-0 cursor-pointer"
phx-click={Prima.Modal.close()}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="text-gray-600 h-5 w-5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
<.modal_title as="h3" class="text-base font-semibold leading-6 text-gray-900">
Good news
</.modal_title>
<p class="mt-3 text-sm text-gray-500">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Consequatur amet labore.
</p>
</div>
<div class="mt-6">
<.button phx-click={Prima.Modal.close()} type="button">
Got it
</.button>
</div>
</.modal_panel>
</div>
</div>
</.modal>
Call Prima.Modal.open("modal-id")
to show the modal. For simple modals with no asynchronous fetching, the open/closed state is fully managed by Prima.
Advanced Usage
Async Loading
Asynchronous modals enable server-side data fetching before displaying content, providing a smooth user experience for dynamic modal content. This pattern is ideal when you need to load user-specific data, perform database queries, or fetch content that depends on user actions.
defmodule PrimaWeb.DemoLive.AsyncModalDemo do
@moduledoc false
use PrimaWeb, :live_component
import Prima.Modal
@impl true
def mount(socket) do
socket = assign(socket, async_modal_open?: false)
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<div>
<.button
id="open-form-modal-button"
type="button"
phx-click={
Prima.Modal.open("demo-form-modal") |> JS.push("open-async-modal", target: @myself)
}
class="inline-flex justify-center rounded-md bg-indigo-600 disabled:bg-gray-400 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
Open Async Modal
</.button>
<.modal
id="demo-form-modal"
on_close={JS.push("close-async-modal", target: @myself)}
class="relative z-10"
>
<.modal_overlay
transition_enter={{"ease-out duration-300", "opacity-0", "opacity-100"}}
transition_leave={{"ease-in duration-200", "opacity-100", "opacity-0"}}
class="fixed inset-0 bg-gray-500/75 transition-opacity"
/>
<div class="fixed inset-0 z-10 w-screen overflow-y-auto">
<div class="flex min-h-full items-end justify-center p-4 sm:items-center sm:p-0">
<.modal_loader>
<svg
class="w-8 h-8 mr-2 text-white/50 animate-spin fill-white"
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</.modal_loader>
<.modal_panel
:if={@async_modal_open?}
id="demo-form-modal-panel"
class="relative overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6"
transition_enter={
{"ease-out duration-300", "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
"opacity-100 translate-y-0 sm:scale-100"}
}
transition_leave={
{"ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
}
>
<div class="relative">
<button
class="absolute top-0 right-0"
phx-click={Prima.Modal.close()}
testing-ref="close-button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="text-gray-600 h-6 w-6"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-100">
<svg
class="h-6 w-6 text-blue-600"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l3.5-1.75a.75.75 0 000-1.342l-3.5-1.75a.75.75 0 00-1.063.853l.708 2.836z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div class="mt-3 text-center sm:mt-5">
<h2
class="text-base font-semibold leading-6 text-gray-900"
id="demo-form-modal-title"
>
Data loaded successfully
</h2>
<div class="mt-2">
<p class="text-sm text-gray-500">
This modal demonstrates loading states and backend synchronization.
</p>
</div>
</div>
</div>
<form class="mt-5 sm:mt-6">
<button
phx-click={JS.push("close-async-modal", target: @myself)}
type="button"
class="inline-flex w-full justify-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Save
</button>
</form>
</.modal_panel>
</div>
</div>
</.modal>
</div>
"""
end
@impl true
def handle_event("open-async-modal", _params, socket) do
{:noreply, assign(socket, async_modal_open?: true)}
end
@impl true
def handle_event("close-async-modal", _params, socket) do
{:noreply, assign(socket, async_modal_open?: false)}
end
end
The async modal pattern uses a two-phase approach. Initially, only the modal backdrop and loading spinner are shown via .modal_loader. The actual modal content in
.modal_panel
is rendered conditionally based on the liveview assigns once the async loading operation is complete.
To implement async modals, your trigger button needs a phx-click
event that pushes to your LiveView (e.g., JS.push("load-modal-data")) in addition to calling Prima.Modal.open("modal-id"). The modal component requires an
on_close
handler to synchronize the closed state with your LiveView.
In your LiveView's handle_event, perform your async operations (database queries, API calls, etc.) and render the modal content conditionally after updating the liveview assigns.
This pattern works particularly well for edit forms, detail views, confirmation dialogs with dynamic content, and any scenario where modal content depends on server-side state or computations.
Form Integration
Modals work seamlessly with Phoenix forms, validation, and submission handling.
defmodule PrimaWeb.DemoLive.FormModalDemo do
@moduledoc false
use PrimaWeb, :live_component
import Prima.{Modal, Combobox}
@impl true
def mount(socket) do
socket =
socket
|> assign(form_modal_open?: false)
|> assign(submitted_form_data: nil)
|> assign(form: to_form(%{"name" => "", "category" => ""}))
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<div>
<.button
id="open-form-demo-button"
type="button"
phx-click={
Prima.Modal.open("form-integration-modal") |> JS.push("open-form-modal", target: @myself)
}
>
Open Modal
</.button>
<div :if={@submitted_form_data} class="mt-4 p-4 bg-green-50 border border-green-200 rounded-md">
<h3 class="text-sm font-medium text-green-800 mb-2">Form Submitted Successfully!</h3>
<div class="text-sm text-green-700">
<p><strong>Name:</strong> {@submitted_form_data.name}</p>
<p><strong>Category:</strong> {@submitted_form_data.category}</p>
</div>
</div>
<.modal
id="form-integration-modal"
on_close={JS.push("close-form-modal", target: @myself)}
class="relative z-10"
>
<.modal_overlay
transition_enter={{"ease-out duration-300", "opacity-0", "opacity-100"}}
transition_leave={{"ease-in duration-200", "opacity-100", "opacity-0"}}
class="fixed inset-0 bg-gray-500/75 transition-opacity"
/>
<div class="fixed inset-0 w-screen overflow-y-auto">
<div class="flex min-h-full items-end justify-center p-4 sm:items-center sm:p-0">
<.modal_loader>
<svg
class="w-8 h-8 mr-2 text-white/50 animate-spin fill-white"
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</.modal_loader>
<.modal_panel
:if={@form_modal_open?}
id="form-integration-modal-panel"
class="relative overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6"
transition_enter={
{"ease-out duration-300", "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
"opacity-100 translate-y-0 sm:scale-100"}
}
transition_leave={
{"ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
}
>
<.form for={@form} phx-submit="save" phx-target={@myself}>
<h2 class="text-base font-semibold leading-7 text-gray-900">New item form</h2>
<div>
<label
for={@form[:name].name}
class="block text-sm font-medium leading-6 text-gray-900"
>
Name
</label>
<input
type="text"
id={@form[:name].id}
name={@form[:name].name}
value={@form[:name].value}
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
/>
</div>
<div class="mt-4">
<label class="block text-sm font-medium leading-6 text-gray-900 mb-2">
Category
</label>
<.combobox class="w-full" id="form-modal-combobox">
<.combobox_input
name={@form[:category].name}
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 cursor-default placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
placeholder="Select a category..."
/>
<.combobox_options
id="form-modal-combobox-options"
transition_leave={{"ease-in duration-100", "opacity-100", "opacity-0"}}
class="z-50 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<%= for option <- ["Technology", "Design", "Marketing", "Sales", "Finance"] do %>
<.combobox_option
value={option}
class="group relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900 data-focus:bg-indigo-600 data-focus:text-white"
>
{option}
</.combobox_option>
<% end %>
</.combobox_options>
</.combobox>
</div>
<div class="mt-5 sm:mt-6">
<.button type="submit" class="w-full">
<svg
class="w-4 h-4 mr-2 text-white/50 animate-spin fill-white hidden phx-click-loading:inline-block"
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
Save
</.button>
</div>
</.form>
</.modal_panel>
</div>
</div>
</.modal>
</div>
"""
end
@impl true
def handle_event("open-form-modal", _params, socket) do
{:noreply, assign(socket, form_modal_open?: true)}
end
@impl true
def handle_event("close-form-modal", _params, socket) do
{:noreply, assign(socket, form_modal_open?: false)}
end
@impl true
def handle_event("save", params, socket) do
name = params["name"] || ""
category = params["category"] || ""
form_data = %{name: name, category: category}
socket =
socket
|> assign(submitted_form_data: form_data)
|> assign(form_modal_open?: false)
{:noreply, socket}
end
end
Use the on_close
attribute to chain JavaScript commands with LiveView events for backend state synchronization.
Browser History
Integrate modals with browser navigation for bookmarkable and shareable modal states.
<.button phx-click={JS.patch("/modal/history") |> Prima.Modal.open("demo-history-modal")}>
Open Modal
</.button>
<.modal
show={@live_action == :modal_history}
id="demo-history-modal"
on_close={JS.patch("/modal")}
class="relative z-10"
>
<.modal_overlay
transition_enter={{"ease-out duration-300", "opacity-0", "opacity-100"}}
transition_leave={{"ease-in duration-200", "opacity-100", "opacity-0"}}
class="fixed inset-0 bg-gray-500/75 transition-opacity"
/>
<div class="fixed inset-0 z-10 w-screen overflow-y-auto">
<div class="flex min-h-full items-end justify-center p-4 sm:items-center sm:p-0">
<.modal_loader>
<svg
class="w-8 h-8 mr-2 text-white/50 animate-spin fill-white"
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</.modal_loader>
<.modal_panel
:if={@live_action == :modal_history}
id="demo-history-modal-panel"
class="relative overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6"
transition_enter={
{"ease-out duration-300", "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
"opacity-100 translate-y-0 sm:scale-100"}
}
transition_leave={
{"ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
}
>
<div>
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<svg
class="h-6 w-6 text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
<div class="mt-3 text-center sm:mt-5">
<h3
class="text-base font-semibold leading-6 text-gray-900"
id="history-demo-modal-title"
>
Payment successful
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Consequatur amet labore.
</p>
</div>
</div>
</div>
<div class="mt-5 sm:mt-6">
<.button phx-click={Prima.Modal.close()} type="button" class="w-full">
Go back to dashboard
</.button>
</div>
</.modal_panel>
</div>
</div>
</.modal>
When integrated with Phoenix LiveView routing, modals respond to URL changes and update the browser's address bar.
# In your router.ex
live "/", DemoLive, :index
live "/modal/history", DemoLive, :modal_history
# In your LiveView module
def handle_params(_params, _url, socket) do
case socket.assigns.live_action do
:modal_history ->
{:noreply, assign(socket, show_history_modal: true)}
_ ->
{:noreply, assign(socket, show_history_modal: false)}
end
end