Modal

Overlay dialog for focused interactions, with size, placement, and backdrop variants.

Basic

.heex
<.button phx-click={Fluxon.open_dialog("reset-password")}>
  Reset password
</.button>

<.modal id="reset-password" class="w-full max-w-[350px] flex flex-col gap-5 p-8">
  <div class="flex flex-col items-center text-center">
    <.icon name="huge-lock-01" class="size-6 text-foreground-soft" />
    <h3 class="font-medium text-lg mt-3">Reset password</h3>
    <p class="text-sm text-foreground-softer">Enter your email to reset your password.</p>
  </div>

  <.input name="email" type="email" label="Email address" />
  <.button variant="solid">Send reset link</.button>
</.modal>
.ex
# The same dialog can also be opened from the server.
def handle_event("forgot_password", _params, socket) do
  {:noreply, Fluxon.open_dialog(socket, "reset-password")}
end

Sizes

Set width through class: responsive max-widths, fixed pixel widths, or full-width.

.heex
<div>
  <div class="flex flex-wrap gap-4 justify-center">
    <.button phx-click={Fluxon.open_dialog("modal-size-max-w-xl")}>Max width</.button>
    <.button phx-click={Fluxon.open_dialog("modal-size-400px")}>Fixed width</.button>
    <.button phx-click={Fluxon.open_dialog("modal-size-w-full")}>Full width</.button>
  </div>

  <.modal id="modal-size-max-w-xl" class="max-w-xl w-full">
    <h3 class="text-lg font-semibold text-foreground">A responsive max-width modal</h3>
    <p class="text-foreground-softer mt-2">
      This modal uses Tailwind's <code>max-w-xl</code>
      and <code>w-full</code>
      for a responsive layout: full width on small screens, capped at xl on larger ones.
    </p>

    <div class="flex justify-end gap-4 mt-6">
      <.button phx-click={Fluxon.close_dialog("modal-size-max-w-xl")}>Cancel</.button>
      <.button variant="solid">Some action</.button>
    </div>
  </.modal>

  <.modal id="modal-size-400px" class="w-[400px]">
    <h3 class="text-lg font-semibold text-foreground">A fixed-width modal</h3>
    <p class="text-foreground-softer mt-2">
      A fixed 400px width keeps layouts stable across screens.
    </p>

    <div class="flex justify-end gap-4 mt-6">
      <.button phx-click={Fluxon.close_dialog("modal-size-400px")}>Cancel</.button>
      <.button variant="solid">Some action</.button>
    </div>
  </.modal>

  <.modal id="modal-size-w-full" class="w-full">
    <h3 class="text-lg font-semibold text-foreground">A full-width modal</h3>
    <p class="text-foreground-softer mt-2">
      A full-width modal spans the entire viewport. Useful for dense dashboards or long-form editors.
    </p>

    <div class="flex justify-end gap-4 mt-6">
      <.button phx-click={Fluxon.close_dialog("modal-size-w-full")}>Cancel</.button>
      <.button variant="solid">Some action</.button>
    </div>
  </.modal>
</div>

Placement

Position the modal near any viewport edge. center is the default; edge-anchored placements slide in with a subtle offset. For full-height drawers or full-width banners, reach for Fluxon.Components.Sheet instead.

.heex
<.button phx-click={Fluxon.open_dialog("modal-placement-top")}>Top</.button>

<.modal id="modal-placement-top" placement="top" class="max-w-md">
  <h3 class="text-lg font-semibold">Top</h3>
  <p>This modal is anchored near the top edge.</p>
</.modal>

<!--
  Available placements:

  center (default), top, bottom, left, right
-->

Custom backdrop

Override the overlay with backdrop_class: blur, blackout, or a tinted color.

.heex
<div>
  <div class="flex flex-wrap gap-4 justify-center">
    <.button phx-click={Fluxon.open_dialog("backdrop-frosted-glass")}>Frosted glass</.button>
    <.button phx-click={Fluxon.open_dialog("backdrop-blackout")}>Blackout</.button>
    <.button phx-click={Fluxon.open_dialog("backdrop-subtle-transparency")}>Light dim</.button>
    <.button phx-click={Fluxon.open_dialog("backdrop-blue")}>Blue tint</.button>
  </div>

  <.modal id="backdrop-frosted-glass" class="max-w-lg" backdrop_class="bg-black/10 backdrop-blur-lg">
    <h3 class="text-lg font-semibold text-foreground">Frosted glass</h3>
    <p class="text-foreground-softer mt-2">
      A subtle dim plus a backdrop blur keeps the page legible while pushing it back.
    </p>
  </.modal>

  <.modal id="backdrop-blackout" class="max-w-lg" backdrop_class="bg-black">
    <h3 class="text-lg font-semibold text-foreground">Blackout</h3>
    <p class="text-foreground-softer mt-2">
      Opaque black hides the page entirely. Good for media viewers and immersive flows.
    </p>
  </.modal>

  <.modal id="backdrop-subtle-transparency" class="max-w-lg" backdrop_class="bg-black/10">
    <h3 class="text-lg font-semibold text-foreground">Light dim</h3>
    <p class="text-foreground-softer mt-2">
      A soft 10% dim keeps the surrounding context fully visible.
    </p>
  </.modal>

  <.modal id="backdrop-blue" class="max-w-lg" backdrop_class="bg-blue-500/30">
    <h3 class="text-lg font-semibold text-foreground">Blue tint</h3>
    <p class="text-foreground-softer mt-2">
      Tint the backdrop with any color to match the brand or signal mood.
    </p>
  </.modal>
</div>

Scrollable layout

Pin a header and footer while letting the middle section scroll. Use p-0 on the modal and pad each region individually.

.heex
<.modal id="activity" class="p-0 max-w-lg w-full">
  <header class="px-5 py-4 border-b border-base">
    <h3 class="text-base font-semibold">Recent activity</h3>
  </header>

  <main class="overflow-y-auto max-h-[400px] px-5 py-4">
    <!-- Long content here -->
  </main>

  <footer class="px-5 py-3 flex justify-end gap-2 border-t border-base">
    <.button phx-click={Fluxon.close_dialog("activity")} size="sm">Close</.button>
  </footer>
</.modal>

Confirmation dialog

The classic destructive-action pattern. Compose close_dialog with JS.push so a single click closes the dialog and dispatches the action.

.heex
<.button color="danger" variant="solid" phx-click={Fluxon.open_dialog("confirm-archive")}>
  Archive project
</.button>

<.modal id="confirm-archive" class="max-w-md">
  <h3 class="text-base font-semibold">Archive project?</h3>
  <p class="text-sm text-foreground-softer mt-1">
    You can restore archived projects from the trash within 30 days.
  </p>

  <.button phx-click={Fluxon.close_dialog("confirm-archive")}>Cancel</.button>
  <.button
    color="danger"
    variant="solid"
    phx-click={
      Fluxon.close_dialog("confirm-archive")
      |> JS.push("archive_project", value: %{id: @project.id})
    }
  >
    Archive
  </.button>
</.modal>
.ex
def handle_event("archive_project", %{"id" => id}, socket) do
  # Archive the project
  {:noreply, put_flash(socket, :info, "Project archived")}
end

Type-to-confirm delete

A high-friction destructive flow. The submit button stays disabled until the user types the exact phrase and ticks the acknowledgement checkbox.

.heex
<.button color="danger" variant="solid" phx-click={Fluxon.open_dialog("delete-project")}>
  Delete project
</.button>

<.modal id="delete-project" class="w-full max-w-md">
  <.form for={@form} phx-change="validate" phx-submit="confirm">
    <h3 class="text-base font-semibold">Delete {@project.name}?</h3>
    <p class="text-sm text-foreground-softer mt-2">
      This action cannot be undone.
    </p>

    <.input field={@form[:phrase]} label={"Type \"delete #{@project.name}\" to confirm"} />
    <.checkbox field={@form[:ack]} label="I acknowledge this will delete all project data." />

    <.button type="submit" variant="solid" color="danger" disabled={!@confirmable?}>
      Delete project
    </.button>
  </.form>
</.modal>
.ex
def handle_event("validate", %{"deletion" => params}, socket) do
  # Re-evaluate whether the phrase and acknowledgement gate is satisfied
  {:noreply, socket}
end

def handle_event("confirm", _params, socket) do
  # Delete the project, then dismiss the dialog
  {:noreply, Fluxon.close_dialog(socket, "delete-project")}
end

Form integration

Form bound to a LiveView changeset. phx-change validates on every keystroke; phx-submit closes the modal on success and shows field-level errors otherwise.

.heex
<.button phx-click={Fluxon.open_dialog("invite-modal")}>Invite teammate</.button>

<.modal id="invite-modal" class="w-full max-w-[440px]">
  <.form for={@form} phx-change="validate" phx-submit="save">
    <.input field={@form[:name]} label="Full name" />
    <.input field={@form[:email]} type="email" label="Email" />
    <.select field={@form[:role]} label="Role" native options={@roles} />

    <.button type="button" phx-click={Fluxon.close_dialog("invite-modal")}>
      Cancel
    </.button>
    <.button type="submit" variant="solid">Send invite</.button>
  </.form>
</.modal>
.ex
def handle_event("validate", %{"user" => params}, socket) do
  # Re-run validation and reassign the form
  {:noreply, socket}
end

def handle_event("save", %{"user" => _params}, socket) do
  # Send the invite, then dismiss the dialog
  {:noreply, Fluxon.close_dialog(socket, "invite-modal")}
end

Image lightbox

Pair a dark backdrop with hide_close_button and a transparent surface to show media without competing chrome.

.heex
<div>
  <.button phx-click={Fluxon.open_dialog("lightbox")}>View image</.button>

  <.modal
    id="lightbox"
    hide_close_button
    backdrop_class="bg-black/90 backdrop-blur-sm"
    class="bg-transparent shadow-none p-0 w-full max-w-3xl"
  >
    <div class="relative">
      <img
        src="https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=1200&q=80"
        alt="Mountain landscape"
        class="w-full rounded-lg"
      />
      <button
        type="button"
        class="absolute top-3 right-3 rounded-md bg-black/40 hover:bg-black/60 px-2 py-1.5 text-white text-xs flex items-center gap-1.5 transition-colors backdrop-blur"
        phx-click={Fluxon.close_dialog("lightbox")}
        aria-label="Close"
      >
        <.icon name="huge-cancel-01" class="size-3.5" /> Esc
      </button>
    </div>
    <p class="text-center text-zinc-300 text-sm mt-3">
      Mountain landscape - photo by Unsplash
    </p>
  </.modal>
</div>

Command palette

placement="top" with a search input and result list. A common pattern for keyboard-first apps.

.heex
<div>
  <.button phx-click={Fluxon.open_dialog("modal-command-palette")}>
    <.icon name="huge-search-01" class="size-4" />
    <span>Search...</span>
    <kbd class="ml-2 text-[10px] px-1.5 py-0.5 rounded border border-base text-foreground-softer">
      Ctrl K
    </kbd>
  </.button>

  <.modal
    id="modal-command-palette"
    placement="top"
    class="w-full max-w-xl p-0 overflow-hidden bg-surface"
    hide_close_button
  >
    <div class="flex items-center gap-3 px-4 h-12 border-b border-base">
      <.icon name="huge-search-01" class="size-4 text-foreground-softest" />
      <input
        type="text"
        placeholder="Type a command or search..."
        class="flex-1 bg-transparent outline-none text-sm text-foreground placeholder:text-foreground-softest"
        data-1p-ignore
      />
      <kbd class="text-[10px] px-1.5 py-0.5 rounded border border-base text-foreground-softer">
        Esc
      </kbd>
    </div>

    <div class="max-h-80 overflow-y-auto p-2">
      <div :for={{group, items} <- command_palette_groups()} class="mb-1 last:mb-0">
        <div class="px-2 pt-2 pb-1 text-[10px] font-semibold text-foreground-softer uppercase tracking-wide">
          {group}
        </div>
        <div class="flex flex-col">
          <button
            :for={{cmd, idx} <- Enum.with_index(items)}
            type="button"
            class={[
              "group flex items-center gap-3 px-2 py-1.5 rounded-md text-left text-sm",
              "hover:highlight",
              group == "Suggested" && idx == 0 && "bg-sunken"
            ]}
            phx-click={Fluxon.close_dialog("modal-command-palette")}
          >
            <.icon name={cmd.icon} class="size-4 text-foreground-softer shrink-0" />
            <span class="flex-1 text-foreground truncate">{cmd.label}</span>
            <span :if={cmd.shortcut} class="flex items-center gap-1">
              <kbd
                :for={key <- cmd.shortcut}
                class="text-[10px] px-1 py-0.5 rounded border border-base text-foreground-softer bg-control"
              >
                {key}
              </kbd>
            </span>
          </button>
        </div>
      </div>
    </div>

    <div class="border-t border-base px-3 py-2 flex items-center justify-between text-[11px] text-foreground-softer">
      <div class="flex items-center gap-3">
        <span class="flex items-center gap-1">
          <kbd class="px-1 py-0.5 rounded border border-base">↑↓</kbd> Navigate
        </span>
        <span class="flex items-center gap-1">
          <kbd class="px-1 py-0.5 rounded border border-base">Enter</kbd> Select
        </span>
      </div>
      <span>{Enum.sum(Enum.map(command_palette_groups(), fn {_, items} -> length(items) end))} commands</span>
    </div>
  </.modal>
</div>

Integration setup

Onboarding flow with selectable methods and a conditional input. The Continue button stays disabled until the chosen method is satisfied.

.heex
<.button phx-click={Fluxon.open_dialog("connect-slack")}>Connect Slack</.button>

<.modal id="connect-slack" class="w-full max-w-md">
  <form phx-change="validate" phx-submit="connect">
    <h3 class="text-base font-semibold">Connect Slack</h3>

    <div class="flex flex-col gap-2 mt-4">
      <button :for={m <- @methods} type="button" phx-click="select" phx-value-method={m.value}>
        {m.label}
      </button>
    </div>

    <.input :if={@method == "api_key"} name="api_key" value={@api_key} label="API key" />

    <.button type="submit" variant="solid" disabled={not @can_continue?}>
      Continue
    </.button>
  </form>
</.modal>
.ex
def handle_event("select", %{"method" => method}, socket) do
  # Switch the active connection method
  {:noreply, socket}
end

def handle_event("validate", %{"api_key" => _api_key}, socket) do
  # Update the API key as the user types
  {:noreply, socket}
end

def handle_event("connect", _params, socket) do
  # Kick off the integration, then dismiss the dialog
  {:noreply, Fluxon.close_dialog(socket, "connect-slack")}
end

Review with inline warning

An inline <.alert> shows the consequence the moment the user picks the action, replacing a separate confirmation step. The expandable target row reveals metadata on demand.

.heex
<.button phx-click={Fluxon.open_dialog("move-domain")}>Move domain</.button>

<.modal id="move-domain" class="max-w-xl w-full">
  <h3 class="text-lg font-semibold">Move domain</h3>

  <div class="mt-4">
    <%!-- Comparison list: current row is locked, target row is expandable --%>
    <div>Current env: fluxon / production</div>
    <button type="button" phx-click="toggle">
      Target env: latterly / production
    </button>
    <div :if={@expanded}>
      <%!-- Target environment metadata --%>
    </div>
  </div>

  <.alert color="warning" hide_close class="mt-5">
    The current and target environments are in different zones.
  </.alert>

  <div class="flex justify-end gap-3 mt-6">
    <.button phx-click={Fluxon.close_dialog("move-domain")}>Cancel</.button>
    <.button variant="solid">Move domain</.button>
  </div>
</.modal>
.ex
def handle_event("toggle", _params, socket) do
  # Toggle the expanded state of the target row
  {:noreply, socket}
end

Multi-step wizard

Multi-step form inside a modal. Each step is validated by its own changeset before Next advances; Previous and clicking any already visited step in the sidebar are always allowed.

.heex
<.modal id="create-project" class="max-w-3xl w-full p-0">
  <div class="flex">
    <aside class="w-48 p-6">
      <ol>
        <li :for={step <- @steps}>
          <button phx-click="goto" phx-value-step={step.number}>
            {step.title}
          </button>
        </li>
      </ol>
    </aside>

    <.form for={@form} phx-submit="next" class="flex-1 p-6">
      <%!-- Render the current step's fields here --%>

      <div class="flex justify-between mt-6">
        <.button :if={@current_step > 1} phx-click="goto" phx-value-step={@current_step - 1}>
          Previous
        </.button>
        <.button type="submit" variant="solid">Next</.button>
      </div>
    </.form>
  </div>
</.modal>
.ex
def handle_event("next", %{"project" => params}, socket) do
  # Validate the current step's changeset, advance on success
  {:noreply, socket}
end

def handle_event("goto", %{"step" => step}, socket) do
  # Jump to an already validated step
  {:noreply, socket}
end

Loading state

Render the loading visual as the modal's default content so it is on screen the moment the dialog opens. Three variants below: a centered spinner, a layout-matching skeleton, and a streaming bar pulse. Each kicks off a 1.5s server roundtrip before the real content takes over.

.heex
<%!-- The modal renders the skeleton as its default content, so it is --%>
<%!-- on screen the moment Fluxon.open_dialog runs. on_close pushes a --%>
<%!-- reset event so reopening always starts back in the loading state. --%>
<.button phx-click={
  JS.push("load_user", value: %{id: 1}) |> Fluxon.open_dialog("user-details")
}>
  View user
</.button>

<.modal
  id="user-details"
  class="w-[440px]"
  on_close={JS.push("reset_user")}
>
  <div :if={!@user} class="space-y-3">
    <div class="size-14 rounded-full bg-zinc-200 animate-pulse" />
    <div class="h-4 w-2/3 rounded bg-zinc-200 animate-pulse" />
  </div>

  <div :if={@user}>
    <h3>{@user.name}</h3>
    <p>{@user.email}</p>
  </div>
</.modal>
.ex
def handle_event("load_user", %{"id" => _id}, socket) do
  # Kick off the real fetch here; send {:user_loaded, user} back when ready
  {:noreply, assign(socket, user: nil)}
end

def handle_info({:user_loaded, user}, socket) do
  {:noreply, assign(socket, user: user)}
end

def handle_event("reset_user", _, socket) do
  {:noreply, assign(socket, user: nil)}
end

Closing behavior

Disable individual dismissal paths with close_on_esc and close_on_outside_click, or block all of them with prevent_closing. The server can always force a close through Fluxon.close_dialog/2 regardless of these flags.

.heex
<div>
  <div class="flex gap-4 flex-wrap justify-center">
    <.button phx-click={Fluxon.open_dialog("modal-closing-esc")}>Disabled ESC</.button>
    <.button phx-click={Fluxon.open_dialog("modal-closing-outside")}>Disabled outside click</.button>
    <.button phx-click={Fluxon.open_dialog("modal-closing-prevent")}>Prevent all closing</.button>
  </div>

  <.modal class="max-w-md" id="modal-closing-esc" close_on_esc={false}>
    <h3 class="text-lg font-semibold text-foreground">ESC disabled</h3>
    <p class="text-foreground-softer">
      Pressing the Escape key will not dismiss this modal. The backdrop click and close button still work.
    </p>
  </.modal>
  <.modal class="max-w-md" id="modal-closing-outside" close_on_outside_click={false}>
    <h3 class="text-lg font-semibold text-foreground">Outside click disabled</h3>
    <p class="text-foreground-softer">
      Clicking the backdrop will not dismiss this modal. ESC and the close button still work.
    </p>
  </.modal>
  <.modal class="max-w-md" id="modal-closing-prevent" prevent_closing={true}>
    <h3 class="text-lg font-semibold text-foreground">All client closing disabled</h3>
    <p class="text-foreground-softer">
      The close button is also hidden. The dialog can only be closed from the server (or by reloading the page). See the Server-driven flow example below for a working demo.
    </p>
  </.modal>
</div>

Hide the close button

hide_close_button removes the built-in close icon in the top-right corner so the modal can ship its own dismissal UI. ESC and outside-click still work unless they are individually disabled.

.heex
<.modal id="welcome" hide_close_button class="max-w-md text-center">
  <h3 class="text-lg font-semibold">Welcome aboard</h3>
  <p class="text-sm text-foreground-softer mt-2">
    Press ESC, click outside, or use the action below to dismiss.
  </p>

  <.button class="mt-6" phx-click={Fluxon.close_dialog("welcome")}>
    Got it
  </.button>
</.modal>

Stacked modals

Multiple modals can be open at once. Fluxon tracks the stack: the topmost dialog stays interactive and focus-trapped, the rest dim. Closing the top one returns focus to its previous holder.

.heex
<.button phx-click={Fluxon.open_dialog("user-details")}>Open user</.button>

<.modal id="user-details" class="max-w-md">
  <h3>{@user.name}</h3>

  <.button color="danger" phx-click={Fluxon.open_dialog("confirm-delete")}>
    Delete user
  </.button>
</.modal>

<.modal id="confirm-delete" class="max-w-sm">
  <h3>Delete {@user.name}?</h3>

  <.button phx-click={Fluxon.close_dialog("confirm-delete")}>Cancel</.button>

  <%!-- Close both dialogs and dispatch the action in one chain --%>
  <.button
    color="danger"
    phx-click={
      Fluxon.close_dialog("confirm-delete")
      |> Fluxon.close_dialog("user-details")
      |> JS.push("delete_user", value: %{id: @user.id})
    }
  >
    Confirm delete
  </.button>
</.modal>
.ex
def handle_event("delete_user", %{"id" => _id}, socket) do
  # Delete the user; the server can also pop dialogs off the stack
  {:noreply,
   socket
   |> Fluxon.close_dialog("confirm-delete")
   |> Fluxon.close_dialog("user-details")}
end

Open from the server

The server can open a modal at any time with Fluxon.open_dialog/2: delayed prompts, push notifications, or any flow where the trigger lives in handle_info/2 rather than a click.

The button does not open the modal directly. It pushes a server event; the LiveView counts down and then calls Fluxon.open_dialog/2 from a handle_info/2.

.heex
<.button phx-click="start_countdown">Trigger from server</.button>

<.modal id="surprise-modal">
  <h3>Opened by the server</h3>
</.modal>
.ex
def handle_event("start_countdown", _, socket) do
  Process.send_after(self(), :open_modal, 3_000)
  {:noreply, socket}
end

def handle_info(:open_modal, socket) do
  {:noreply, Fluxon.open_dialog(socket, "surprise-modal")}
end

Close from the server

Lock down dismissal with prevent_closing and let the server close the modal once the workflow finishes.

.heex
<.modal id="payment-modal" prevent_closing class="w-full max-w-md">
  <h3>Confirm payment</h3>

  <.button phx-click="process_payment" disabled={@processing?}>
    Pay $190.00
  </.button>
</.modal>
.ex
def handle_event("process_payment", _, socket) do
  # Kick off the real charge here; send {:payment_result, result} back when ready
  {:noreply, assign(socket, processing?: true)}
end

def handle_info({:payment_result, {:ok, _charge}}, socket) do
  {:noreply,
   socket
   |> assign(processing?: false)
   |> Fluxon.close_dialog("payment-modal")}
end