Skip to content

Remote dialog

remote-dialog is the canonical pattern for server-owned modal flows: the trigger button fetches a complete <dialog> fragment from the server, the installRemoteDialog behavior opens it, and installCloseDialog closes it again when the form inside submits successfully.

Also known as: ajax modal, server-rendered modal.

Click Edit item… — the button fetches a complete <dialog> fragment from a real endpoint under api/recipes/remote-dialog/, and installRemoteDialog opens it after the swap. Clear the Name field and save: the server answers 422 with the dialog re-rendered in its error state, and it stays open. A valid save returns an empty 200 — the dialog disappears and a toast names what was saved.

A trigger and a host element:

<button
class="hc-button"
type="button"
data-hx-get="/items/123/edit"
data-hx-target="#dialog-root"
data-hx-swap="innerHTML">
Edit
</button>
<div id="dialog-root" data-hc-remote-dialog-root></div>

The host element has two markers:

  • id="dialog-root" — htmx swap target.
  • data-hc-remote-dialog-root — opt-in for the installRemoteDialog behavior.

The server returns a complete dialog:

<!-- GET /items/123/edit -->
<dialog class="hc-dialog" aria-labelledby="dialog-title">
<header class="hc-dialog__header">
<h2 class="hc-dialog__title" id="dialog-title">Edit item</h2>
</header>
<form
id="edit-item"
data-hx-post="/items/123"
data-hx-target="closest dialog"
data-hx-swap="outerHTML"
data-hc-close-dialog-on-success>
<div class="hc-dialog__body">
<div class="hc-field">
<label class="hc-field__label" for="name">Name</label>
<input id="name" class="hc-input" name="name" value="Acme">
</div>
</div>
</form>
<footer class="hc-dialog__footer">
<!-- method="dialog" closes the dialog natively — no JS. -->
<form method="dialog"><button class="hc-button">Cancel</button></form>
<button class="hc-button" data-variant="primary" type="submit"
form="edit-item">Save</button>
</footer>
</dialog>

Cancel sits in its own <form method="dialog"> — submitting a dialog-method form closes the dialog natively, with no JavaScript and nothing a CSP can object to. Because forms cannot nest, the footer lives outside the edit form, and the Save button reaches back to it with the form="edit-item" attribute.

  1. The user clicks Edit. htmx sends GET /items/123/edit.
  2. htmx swaps the response into #dialog-root (innerHTML).
  3. htmx:afterSwap fires on #dialog-root. The installRemoteDialog behavior sees data-hc-remote-dialog-root on the target, finds the first <dialog> descendant, and calls showModal().
  4. The user edits and submits the form. The form has data-hx-post="/items/123" plus data-hc-close-dialog-on-success.
  5. The server returns either the updated row HTML (swapped into closest dialog — replacing the dialog) or a 4xx with the form re-rendered with validation errors (still inside the dialog).
  6. On 2xx, installCloseDialog sees the success and calls dialog.close(). The dialog disappears.

If the swap into the dialog already replaces the dialog element itself (the closest dialog target with outerHTML does that), installCloseDialog is a no-op for that request — the dialog is already gone.

WhenReturn
Initial trigger (GET)A full <dialog class="hc-dialog">…</dialog> fragment.
Successful submit (POST)The updated content for the page target (e.g. the row), via the dialog’s data-hx-target / data-hx-swap.
Validation failure (422)The whole dialog re-rendered in its error state (data-invalid="true" / aria-invalid="true" on the bad fields) plus HX-Retarget: #dialog-root and HX-Reswap: innerHTML, and the one-time htmx:beforeSwap allowance for 422. Retargeting the root matters: re-swapping the open dialog with outerHTML would insert a fresh closed <dialog> and fire afterSwap on it — not on the root installRemoteDialog watches — so the error state would land invisible. Swapping the root re-runs the behavior and the dialog re-opens showing the errors. (installCloseDialog only closes on 2xx, so nothing dismisses it.)

For server-driven toast feedback include an HX-Trigger header on the success response:

HX-Trigger: {"hc:toast":{"message":"Saved.","variant":"success"}}
  • The dialog uses the native <dialog> element with showModal(). The browser handles focus trap, Escape-to-close, and inert background.
  • Declare a labeled title (#dialog-title) and either:
    • Set aria-labelledby="dialog-title" on the <dialog>, or
    • Make the title the first focusable element so AT picks it up.
  • The Cancel button also closes the dialog — via the native <form method="dialog"> pattern above, the same one the dialog component blesses. No click handler, no behavior, CSP-safe.
  • Without htmx, <button data-hx-get="…"> does nothing. To keep the Edit action working without JavaScript, make the trigger a link (a <button> may not sit inside an <a> — that is invalid HTML):

    <a class="hc-button" href="/items/123/edit"
    data-hx-get="/items/123/edit"
    data-hx-target="#dialog-root"
    data-hx-swap="innerHTML">Edit</a>

    htmx intercepts the click when it is loaded; otherwise the href navigates to a full-page edit screen.

  • Without hc.behaviors.js, the dialog fragment lands in the page but does not auto-open. Add a <script> right after the <dialog> in the server response that opens it — e.g. <script>document.currentScript.previousElementSibling.showModal()</script> — or fall back to inline rendering.