Fluxon.Components.Modal
(Fluxon v3.0.0)
Renders a modal dialog: an overlay anchored over the page that captures focus until dismissed.
Modals are intended for focused interactions that need the user's full attention,
confirmations, forms, detail views, or any flow where the surrounding page should
pause while the user works in the dialog. The component manages focus trapping,
backdrop dimming, body-scroll locking, stacking when several dialogs are open at once,
and enter/leave animations. Visibility is controllable from both the client (via JS
commands) and the server (via Fluxon.open_dialog/2 and Fluxon.close_dialog/2).
Modal vs Sheet
Modal and Sheet share the same underlying component and the same API; they only
differ in visual presentation and motion. Modals sit centered over the viewport
and animate with a scale fade. Sheets slide in from a viewport edge and are better
suited for side panels, drawers, and mobile-style bottom sheets. Reach for
Fluxon.Components.Sheet when the panel should feel attached to an edge; reach
for modal/1 when the dialog should command the center of the screen.
Usage
The simplest case opens the modal from a button on the client and closes it through the built-in close button, the Escape key, or a click on the backdrop.
<.button phx-click={Fluxon.open_dialog("welcome")}>Open</.button>
<.modal id="welcome">
<h2 class="text-lg font-semibold">Welcome</h2>
<p>Modal content goes here.</p>
<.button phx-click={Fluxon.close_dialog("welcome")}>Close</.button>
</.modal>To show the modal as soon as the LiveView mounts, pass open:
<.modal id="onboarding" open>
Onboarding content
</.modal>Opening and Closing
Modals can be controlled from the client, from the server, or from a mix of both. Choose the approach that best matches where the source of truth for visibility lives.
Client-Side Control
Fluxon.open_dialog/1 and Fluxon.close_dialog/1 return Phoenix.LiveView.JS
command chains. Use them in phx-click (or any phx-* JS attribute) to toggle
the modal entirely on the client, with no server roundtrip:
<.button phx-click={Fluxon.open_dialog("settings")}>Settings</.button>
<.modal id="settings">
<.button phx-click={Fluxon.close_dialog("settings")}>Done</.button>
</.modal>Because both functions return JS chains, you can combine them with other commands
using the |> operator (see the dynamic content example below).
Server-Side Control via open
The open attribute is a declarative binding to a LiveView assign. The modal
shows or hides whenever the assign changes:
<.button phx-click="show-details">Show details</.button>
<.modal id="details" open={@show_details}>
Details content
</.modal>Server-Side Control via Helpers
Fluxon.open_dialog/2 and Fluxon.close_dialog/2 accept a socket and push the
state change down to the client without requiring a tracked assign:
def handle_event("show-details", _params, socket) do
{:noreply, Fluxon.open_dialog(socket, "details")}
end
def handle_event("hide-details", _params, socket) do
{:noreply, Fluxon.close_dialog(socket, "details")}
endReconciling client and server state
When you bind visibility through open={@show_modal}, client-side dismissals
(Escape, backdrop click, the close button) close the modal in the browser
immediately but do not update @show_modal on the server. The next render that
re-evaluates the assign will reopen the modal, leaving you with a state mismatch.
You have two options:
Pair
open={@show_modal}withon_close={JS.push("hide_modal")}so every dismissal pushes a server event that resets the assign. This is the recommended approach for most cases.Set
prevent_closingto disable all client-side dismissal paths, and close the modal exclusively from server callbacks. Use this when the dialog must not be dismissed mid-flow, for example during a multi-step form or a critical confirmation.
Dynamic Content
A single modal instance can be reused to display different content by combining
Fluxon.open_dialog/1 with a JS.push/2 that updates a server assign:
<.modal id="user-details" on_close={JS.push("reset-user-details")}>
<div :if={@user_details}>
<p>ID: {@user_details.id}</p>
<p>Name: {@user_details.name}</p>
</div>
</.modal>
<.button phx-click={
Fluxon.open_dialog("user-details")
|> JS.push("load-user-details", value: %{user_id: 1})
}>
View John Doe
</.button>The dialog opens instantly on the client while the server fetches and assigns the
details. When the modal closes, on_close resets the assign so stale content does
not flash on the next open.
For a smoother experience, give the modal a fixed width and render a loading state until the data arrives:
<.modal id="user-details" class="w-[400px]" on_close={JS.push("reset-user-details")}>
<div class="min-h-[200px] relative">
<div :if={!@user_details} class="absolute inset-0 flex items-center justify-center">
<.loading />
</div>
<div :if={@user_details}>
<p>ID: {@user_details.id}</p>
<p>Name: {@user_details.name}</p>
</div>
</div>
</.modal>Placement
The placement attribute positions the modal within the viewport. The default,
center, suits most dialogs; the other options anchor the modal near a viewport
edge while keeping a small amount of breathing room around it.
| Placement | Position | Use Case |
|---|---|---|
center | Centered horizontally and vertically | Default. Confirmations, focused forms, detail views. |
top | Near the top, centered horizontally | Notification-style dialogs, command palettes, search overlays. |
bottom | Near the bottom, centered horizontally | Contextual prompts that should stay close to thumb reach on mobile. |
left | Near the left, centered vertically | Compact side panels with breathing room around them. |
right | Near the right, centered vertically | Compact side panels with breathing room around them. |
center uses a scale fade; the edge-anchored placements slide in from their edge
with a subtle offset. For full-height drawers or full-width banners that go
edge-to-edge, use Fluxon.Components.Sheet.
<.modal id="search" placement="top" class="w-[480px]">
<input type="search" placeholder="Search..." class="w-full" />
</.modal>
<.modal id="filters" placement="right" class="w-80">
<!-- Filter content -->
</.modal>Placement is set at mount
The animation keyframes are picked once when the modal is initialized based on
the rendered placement. Changing the assign dynamically after mount is not a
supported scenario; render distinct modals if you need different placements.
Sizing and Scrolling
By default, the modal sizes itself to fit its content and the wrapper scrolls when
the content exceeds the viewport height. For larger or more structured layouts,
set an explicit width through class and let an inner section own the scroll:
<.modal id="users" class="w-[600px]">
<header class="border-b border-zinc-100 px-6 py-4">
<h2 class="text-lg font-semibold">Users</h2>
</header>
<div class="max-h-[400px] overflow-y-auto px-6 py-4">
<div :for={user <- @users} class="py-2">
<div class="font-medium">{user.name}</div>
<div class="text-sm text-zinc-600">{user.email}</div>
</div>
</div>
<footer class="border-t border-zinc-100 px-6 py-4 flex justify-end gap-3">
<.button phx-click={Fluxon.close_dialog("users")}>Cancel</.button>
<.button>Save</.button>
</footer>
</.modal>This pattern keeps the header and footer pinned while only the middle section scrolls, which is useful for long lists or dense forms.
Stacking
Multiple modals can be open at the same time. The component manages a global stack: the most recently opened modal becomes the foreground (interactive, focus-trapped) and the rest fade into the background, dimmed but still visible. Closing the topmost modal returns focus to whatever held it before that modal opened, and the body scroll lock is released only after the entire stack is empty.
<.modal id="user-details">
<h3 class="text-lg font-semibold">User Details</h3>
<p>Name: {@user.name}</p>
<.button phx-click={Fluxon.close_dialog("user-details")}>Close</.button>
<.button phx-click={Fluxon.open_dialog("confirm-delete")} color="red">
Delete User
</.button>
</.modal>
<.modal id="confirm-delete">
<h3 class="text-lg font-semibold">Confirm Deletion</h3>
<p>This action cannot be undone.</p>
<.button phx-click={Fluxon.close_dialog("confirm-delete")}>Cancel</.button>
<.button
color="red"
phx-click={
Fluxon.close_dialog("confirm-delete")
|> Fluxon.close_dialog("user-details")
|> JS.push("delete_user", value: %{user_id: @user.id})
}
>
Confirm
</.button>
</.modal>Because Fluxon.close_dialog/1 returns a JS chain, you can compose actions that
close several modals and push a server event in a single click.
Focus
When the modal opens, focus moves to the dialog container itself rather than to
the first control, so no control shows a focus ring until the user tabs to one.
To place initial focus on a specific control, add the autofocus attribute to it:
<.modal id="new-user" class="w-[400px]">
<.form for={@form} phx-submit="save_user">
<.input field={@form[:name]} label="Name" autofocus />
<.input field={@form[:email]} type="email" label="Email" />
</.form>
</.modal>While the modal is open, focus stays trapped inside it. Tab moves focus to the
next focusable element and wraps to the first after reaching the last, while
Shift + Tab moves backward and wraps to the last after the first. When the
modal closes, focus returns to the element that had it before the modal opened,
which keeps keyboard navigation continuous across open and close. Pressing Esc
dismisses the topmost modal unless close_on_esc is false or prevent_closing
is true.
In a stack of open modals, only the foreground modal traps focus and responds to the keyboard. Background modals release the trap until they return to the foreground.
Forms
Forms inside a modal work exactly like any other LiveView form. A common pattern is to close the modal from the success branch of the submit handler:
<.modal id="new-user" class="w-[400px]">
<.form for={@form} phx-submit="save_user">
<h2 class="text-lg font-semibold mb-4">New User</h2>
<.input field={@form[:name]} label="Name" />
<.input field={@form[:email]} type="email" label="Email" />
<div class="flex justify-end gap-3 mt-6">
<.button phx-click={Fluxon.close_dialog("new-user")}>Cancel</.button>
<.button type="submit" phx-disable-with="Creating...">Create</.button>
</div>
</.form>
</.modal>def handle_event("save_user", %{"user" => params}, socket) do
case Accounts.create_user(params) do
{:ok, _user} ->
{:noreply,
socket
|> put_flash(:info, "User created")
|> Fluxon.close_dialog("new-user")}
{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
endExamples
Confirmation Dialog
<.button phx-click={Fluxon.open_dialog("confirm-archive")} color="red">
Archive
</.button>
<.modal id="confirm-archive">
<h3 class="text-lg font-semibold">Archive project?</h3>
<p class="text-sm text-zinc-600 mt-2">
You can restore archived projects from the trash within 30 days.
</p>
<div class="flex justify-end gap-3 mt-6">
<.button phx-click={Fluxon.close_dialog("confirm-archive")}>Cancel</.button>
<.button
color="red"
phx-click={
Fluxon.close_dialog("confirm-archive")
|> JS.push("archive-project", value: %{id: @project.id})
}
>
Archive
</.button>
</div>
</.modal>Server-Driven Critical Flow
Use prevent_closing together with server-side helpers when the dialog must stay
open until the workflow finishes.
<.modal id="payment" open={@payment_open} prevent_closing class="w-[480px]">
<h2 class="text-lg font-semibold">Confirm payment</h2>
<!-- payment form -->
</.modal>def handle_event("submit-payment", params, socket) do
case Payments.charge(params) do
{:ok, _} ->
{:noreply,
socket
|> assign(:payment_open, false)
|> Fluxon.close_dialog("payment")}
{:error, reason} ->
{:noreply, assign(socket, error: reason)}
end
endNotification-Style Search Bar
<.modal id="search" placement="top" class="w-[480px]">
<input
type="search"
placeholder="Search the docs..."
class="w-full"
phx-keyup="search"
phx-debounce="200"
/>
<ul :if={@results != []} class="mt-3 divide-y divide-zinc-100">
<li :for={result <- @results} class="py-2">
<a href={result.url} class="block hover:bg-zinc-50">{result.title}</a>
</li>
</ul>
</.modal>Synced Visibility with on_close
<.modal
id="profile"
open={@profile_open}
on_close={JS.push("close-profile")}
>
<!-- profile content -->
</.modal>def handle_event("close-profile", _params, socket) do
{:noreply, assign(socket, profile_open: false)}
endCustom Backdrop Styling
<.modal
id="immersive"
backdrop_class="bg-zinc-900/90 backdrop-blur-sm"
hide_close_button
>
<!-- immersive content with its own dismiss UI -->
<.button phx-click={Fluxon.close_dialog("immersive")}>Close</.button>
</.modal>
Summary
Components
Renders a modal dialog overlay.
Components
Renders a modal dialog overlay.
Use this component for focused interactions that should pause the surrounding page,
confirmations, forms, detail views, or critical workflows. The modal manages its own
focus trap, body-scroll locking, stacking with other open dialogs, and enter/leave
animations selected from the placement value. Visibility can be driven from the
client through Fluxon.open_dialog/1 and Fluxon.close_dialog/1, from the server
through Fluxon.open_dialog/2 and Fluxon.close_dialog/2, or declaratively through
the open attribute.
Examples
<.button phx-click={Fluxon.open_dialog("welcome")}>Open</.button>
<.modal id="welcome">
Modal content
<.button phx-click={Fluxon.close_dialog("welcome")}>Close</.button>
</.modal><.modal id="settings" placement="right" class="w-80">
<!-- compact side panel -->
</.modal><.modal id="confirm" open={@confirm_open} on_close={JS.push("close-confirm")}>
<!-- synced with @confirm_open -->
</.modal>Attributes
id(:string) (required) - Unique identifier for the modal. Required to wire up server-sideFluxon.open_dialog/2andFluxon.close_dialog/2calls, and any client-sideFluxon.open_dialog/1orFluxon.close_dialog/1chains that target this dialog. Must be unique across every modal and sheet on the page.open(:boolean) - Controls whether the modal is visible. Bind this to a server assign for declarative visibility, or passtrueto display the modal as soon as the LiveView mounts. When pairing with client-side dismissals, useon_closeto keep the assign in sync. Defaults tofalse.Defaults to
false.on_close(Phoenix.LiveView.JS) -Phoenix.LiveView.JScommands to run after the modal finishes its leave animation. Fires for every dismissal path: server-pushed close, Escape, backdrop click, and the close button.Defaults to
%Phoenix.LiveView.JS{ops: []}.on_open(Phoenix.LiveView.JS) -Phoenix.LiveView.JScommands to run after the modal finishes its enter animation. Defaults to%Phoenix.LiveView.JS{ops: []}.class(:any) - Additional classes merged onto the inner content container. Use this to set the modal's width or max-width (for examplew-[480px]), tweak padding, round only certain corners for edge-anchored placements, or override surface colors.Defaults to
nil.close_on_esc(:boolean) - Determines whether pressing the Escape key dismisses the modal. Set tofalsewhen the modal hosts a flow that must be completed before the user moves on, while still allowing programmatic dismissals from the server or from explicit in-content buttons. Defaults totrue.Defaults to
true.close_on_outside_click(:boolean) - Determines whether clicking the backdrop dismisses the modal. Set tofalseto require the user to dismiss explicitly via the close button or another in-content control, which protects against accidental dismissals when content extends near the viewport edges. Defaults totrue.Defaults to
true.prevent_closing(:boolean) - Disables every client-side dismissal path at once: Escape key, backdrop click, and the built-in close button (which is also hidden). The dialog stays open until it is closed from the server throughFluxon.close_dialog/2or by toggling theopenassign. Use this for critical workflows like payment confirmations or multi-step forms where mid-flow dismissal would corrupt the state. Defaults tofalse.Defaults to
false.hide_close_button(:boolean) - Hides the built-in close button in the top-right corner. Use this when the content provides its own dismissal UI, when the design calls for a chrome-free overlay, or when dismissal should only happen through a specific action. Other dismissal paths (Escape, backdrop click) still work unless they are individually disabled. Defaults tofalse.Defaults to
false.backdrop_class(:string) - Additional classes merged onto the backdrop element. Useful for changing the overlay color, opacity, or applying abackdrop-blur-*utility for a frosted glass effect.Defaults to
nil.placement(:string) - Positions the modal within the viewport and selects the matching enter/leave animation. Defaults tocenter.center: Centered horizontally and vertically. Best default for confirmations, forms, and detail views.top: Anchored near the top edge with horizontal centering. Good for notification-style dialogs, command palettes, or search overlays.bottom: Anchored near the bottom edge with horizontal centering. Good for contextual prompts that should stay close to thumb reach on mobile.left: Anchored near the left edge with vertical centering. A compact side panel with breathing room around it.right: Anchored near the right edge with vertical centering. Mirror ofleft.
For edge-to-edge full-height drawers or full-width banners, use
Fluxon.Components.Sheetinstead.The selected placement is read once at mount; changing it dynamically after mount is not supported.
Defaults to
"center". Must be one of"center","top","bottom","left", or"right".
Slots
inner_block(required) - Body of the modal. Accepts arbitrary HEEx, including headers, scrollable sections, forms, and footer rows of action buttons. Set explicit dimensions on the inner content throughclassto keep the layout stable when content loads asynchronously.