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.
Live demo
Section titled “Live demo”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.
Basic usage
Section titled “Basic usage”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 theinstallRemoteDialogbehavior.
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.
What happens
Section titled “What happens”- The user clicks Edit. htmx sends
GET /items/123/edit. - htmx swaps the response into
#dialog-root(innerHTML). htmx:afterSwapfires on#dialog-root. TheinstallRemoteDialogbehavior seesdata-hc-remote-dialog-rooton the target, finds the first<dialog>descendant, and callsshowModal().- The user edits and submits the form. The form has
data-hx-post="/items/123"plusdata-hc-close-dialog-on-success. - 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). - On 2xx,
installCloseDialogsees the success and callsdialog.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.
Server response contract
Section titled “Server response contract”| When | Return |
|---|---|
| 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"}}Accessibility
Section titled “Accessibility”- The dialog uses the native
<dialog>element withshowModal(). 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.
- Set
- 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.
Progressive enhancement
Section titled “Progressive enhancement”-
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
hrefnavigates 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.
Related
Section titled “Related”- Dialog component
- Confirm action recipe — pre-flight confirm without the round trip.
- Filter popover recipe — non-modal sibling.