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")}
end

Reconciling 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:

  1. Pair open={@show_modal} with on_close={JS.push("hide_modal")} so every dismissal pushes a server event that resets the assign. This is the recommended approach for most cases.

  2. Set prevent_closing to 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.

PlacementPositionUse Case
centerCentered horizontally and verticallyDefault. Confirmations, focused forms, detail views.
topNear the top, centered horizontallyNotification-style dialogs, command palettes, search overlays.
bottomNear the bottom, centered horizontallyContextual prompts that should stay close to thumb reach on mobile.
leftNear the left, centered verticallyCompact side panels with breathing room around them.
rightNear the right, centered verticallyCompact 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
end

Examples

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
end
<.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)}
end

Custom 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

modal(assigns)

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-side Fluxon.open_dialog/2 and Fluxon.close_dialog/2 calls, and any client-side Fluxon.open_dialog/1 or Fluxon.close_dialog/1 chains 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 pass true to display the modal as soon as the LiveView mounts. When pairing with client-side dismissals, use on_close to keep the assign in sync. Defaults to false.

    Defaults to false.

  • on_close (Phoenix.LiveView.JS) - Phoenix.LiveView.JS commands 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.JS commands 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 example w-[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 to false when 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 to true.

    Defaults to true.

  • close_on_outside_click (:boolean) - Determines whether clicking the backdrop dismisses the modal. Set to false to 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 to true.

    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 through Fluxon.close_dialog/2 or by toggling the open assign. Use this for critical workflows like payment confirmations or multi-step forms where mid-flow dismissal would corrupt the state. Defaults to false.

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

    Defaults to false.

  • backdrop_class (:string) - Additional classes merged onto the backdrop element. Useful for changing the overlay color, opacity, or applying a backdrop-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 to center.

    • 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 of left.

    For edge-to-edge full-height drawers or full-width banners, use Fluxon.Components.Sheet instead.

    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 through class to keep the layout stable when content loads asynchronously.