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.

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"
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">
<header class="hc-dialog__header">
<h2 class="hc-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 (4xx)The form re-rendered with data-invalid="true" and aria-invalid="true" on the bad fields. The dialog stays open because installCloseDialog only closes on 2xx.

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.