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.
Basic HTML
Section titled “Basic HTML”<!-- commandfor + command open the dialog natively: no script, no listener --><button class="hc-button" data-variant="primary" type="button" commandfor="my-dialog" command="show-modal"> Open dialog</button>
<dialog class="hc-dialog" id="my-dialog" aria-labelledby="my-dialog-title"> <header class="hc-dialog__header"> <h2 class="hc-dialog__title" id="my-dialog-title">Confirm something</h2> </header> <div class="hc-dialog__body"> <p>Body content.</p> </div> <footer class="hc-dialog__footer"> <!-- form method="dialog" closes the dialog natively, no JS needed --> <form method="dialog"><button class="hc-button">Cancel</button></form> <form method="dialog"><button class="hc-button" data-variant="primary">OK</button></form> </footer></dialog>| 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.
Tall dialogs
Section titled “Tall dialogs”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.
Open and close
Section titled “Open and close”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"callsshowModal()on the elementcommandfornames by id — focus trap,::backdrop, Escape, and focus returning to the opener on close are all the browser’s.command="close"callsclose(). It works from anywhere, including inside an htmx form, where aformmethod="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 ::backdropdialog.show(); // non-modal, no backdropdialog.close(); // close (modal or non-modal)dialog.close('confirm'); // close and set returnValueListen for the close event to react to dismissal:
dialog.addEventListener('close', () => { if (dialog.returnValue === 'confirm') { // user accepted }});htmx usage
Section titled “htmx usage”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.
Accessibility
Section titled “Accessibility”- Use the native
<dialog>element. Do not simulate a dialog with a<div role="dialog">unless you genuinely cannot useshowModal(). - The browser handles focus trap and Escape automatically when the
dialog is opened via
showModal()(orcommand="show-modal"), and returns focus to the opener when it closes. - Label the dialog. Either:
- Set
aria-labelledbyon the dialog pointing at the title id, or - Place the title before any focusable element so it is read on open.
- Set
- For destructive confirms, focus the Cancel button on open — never the destructive action.
Theming tokens
Section titled “Theming tokens”| 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. |
CSS variables
Section titled “CSS variables”Show the generated CSS variables
--hc-dialog-bg | -fg | -border--hc-dialog-radius | -padding | -gap | -max-width | -duration--hc-dialog-backdropRelated
Section titled “Related”- Popover component — non-modal sibling.
- Confirm action recipe
- Remote dialog recipe
Used in recipes: Remote dialog · Session expiry