Skip to content

Dialog

hc-dialog is a thin class applied to the standard <dialog> element. The browser owns the modal lifecycle — focus trap, Escape-to-close, inert background, top-layer rendering — and the component adds only the visual surface.

Also known as: modal, modal dialog.

Confirm something

Body content.

Part Purpose
.hc-dialog Root <dialog> element.
.hc-dialog__header Title row, with a bottom divider.
.hc-dialog__title Heading inside the header.
.hc-dialog__body Main content area — scrolls when the dialog is taller than the viewport.
.hc-dialog__footer Action row, right-aligned, top divider.

The parts are conventional — you can omit any of them.

An open dialog is a column, so content taller than the viewport scrolls inside the body while the header and footer stay put. That matters because the primary action lives in the footer: a filter panel with a dozen fields would otherwise push it off screen.

One form may wrap all three parts — the usual shape when the footer’s button submits the body’s fields:

<dialog class="hc-dialog" id="filters" aria-labelledby="filters-title">
<form method="get" action="/orders">
<div class="hc-dialog__header">
<h2 class="hc-dialog__title" id="filters-title">Filters</h2>
</div>
<div class="hc-dialog__body">…many fields…</div>
<div class="hc-dialog__footer">
<button class="hc-button" type="submit" data-variant="primary">Apply</button>
</div>
</form>
</dialog>

Give a wide panel more room with --hc-dialog-max-width; the height cap comes from the browser and needs no setting.

A <dialog> has no click-to-open attribute of its own, but HTML’s invoker commands give any button one — declarative, no script, no listener, and nothing a Content-Security-Policy: default-src 'self' can object to:

<button class="hc-button" type="button"
commandfor="my-dialog" command="show-modal">Open</button>
<dialog class="hc-dialog" id="my-dialog">
…
<button class="hc-button" type="button"
commandfor="my-dialog" command="close">Close</button>
</dialog>
  • command="show-modal" calls showModal() on the element commandfor names by id — focus trap, ::backdrop, Escape, and focus returning to the opener on close are all the browser’s.
  • command="close" calls close(). It works from anywhere, including inside an htmx form, where a formmethod="dialog" button is captured by htmx and never reaches the dialog (the remote-dialog recipe keeps its Cancel in a separate <form method="dialog"> for that reason — either idiom is fine).
  • Both need type="button": a submit button ignores its command and submits its form instead.

For a non-modal show(), or to close with a return value, use the native methods:

dialog.showModal(); // modal, with focus trap and ::backdrop
dialog.show(); // non-modal, no backdrop
dialog.close(); // close (modal or non-modal)
dialog.close('confirm'); // close and set returnValue

Listen for the close event to react to dismissal:

dialog.addEventListener('close', () => {
if (dialog.returnValue === 'confirm') {
// user accepted
}
});

For modals whose body comes from the server, see the Remote dialog recipe. For a pre-flight confirmation step (no round trip until accepted), see Confirm action.

For a form inside a dialog that should close on success, opt in with data-hc-close-dialog-on-success:

<form
data-hx-post="/items"
data-hx-target="closest dialog"
data-hx-swap="outerHTML"
data-hc-close-dialog-on-success>
…
</form>

This glue is the installCloseDialog() behavior — it listens for htmx:afterRequest, checks for success, and calls dialog.close() on the closest <dialog> ancestor. installCloseDialog() is idempotent and returns an uninstaller. The zero-config @hypermedia-components/core/behaviors entry auto-installs it and picks up content swapped in by htmx.

  • Use the native <dialog> element. Do not simulate a dialog with a <div role="dialog"> unless you genuinely cannot use showModal().
  • The browser handles focus trap and Escape automatically when the dialog is opened via showModal() (or command="show-modal"), and returns focus to the opener when it closes.
  • Label the dialog. Either:
    • Set aria-labelledby on the dialog pointing at the title id, or
    • Place the title before any focusable element so it is read on open.
  • For destructive confirms, focus the Cancel button on open — never the destructive action.
Token path Purpose
dialog.bg / -fg Surface colors.
dialog.border Border around the dialog.
dialog.radius Border radius.
dialog.padding Padding for each part.
dialog.gap Gap between footer buttons.
dialog.max-width Maximum inline size.
dialog.backdrop ::backdrop color.
dialog.duration Open / close transition time.
Show the generated CSS variables
--hc-dialog-bg | -fg | -border
--hc-dialog-radius | -padding | -gap | -max-width | -duration
--hc-dialog-backdrop

Used in recipes: Remote dialog · Session expiry