Confirm action
confirm-action adds a modal confirmation step in front of a
destructive action — an htmx request or a plain form submission. The
button stays a normal <button>; htmx remains the sole owner of the
network request; a small behavior shim opens the dialog and re-emits a
hc:confirmed event when the user accepts. For a submit button in a
plain (non-htmx) form, the behavior also completes the intercepted
submission via form.requestSubmit().
Also known as: confirmation dialog, are-you-sure prompt.
Live demo
Section titled “Live demo”Click Delete and confirm — the row disappears via the outerHTML
swap and a real HX-Trigger header raises a toast naming the deleted
item. Unlike the no-server preview under Basic usage below (which only
reports the outcome), this table round-trips through a real endpoint: a
namespaced demo implementation of the server contract under
api/recipes/confirm-action/. The server is stateless, so Reset
rows simply refetches the rows.
| Item | Actions |
|---|
Basic usage
Section titled “Basic usage”Add data-hc-confirm to any element that issues an htmx request, and
gate the htmx trigger on hc:confirmed:
<button class="hc-button" data-variant="error" type="button" data-hc-confirm="Delete this item?" data-hx-delete="/items/123" data-hx-trigger="hc:confirmed" data-hx-target="closest tr" data-hx-swap="outerHTML"> Delete</button>Try it — clicking Delete opens the shared confirm dialog. (This demo has no server, so confirming just reports the outcome instead of firing an htmx request.)
<button class="hc-button" data-variant="error" type="button" data-hc-confirm="Delete this item?" data-hx-delete="/items/123" data-hx-trigger="hc:confirmed" data-hx-target="closest tr" data-hx-swap="outerHTML"> Delete</button>What happens:
- The user clicks the button.
- The behavior intercepts the click and shows the shared confirm dialog. The original click is suppressed; htmx does not fire yet.
- If the user confirms, the behavior dispatches a bubbling
hc:confirmedevent on the original button. - htmx, listening via
data-hx-trigger="hc:confirmed", sends the request. - The server returns HTML for the target area (and optionally
HX-Triggerevents).
If the user cancels, no event is dispatched and nothing happens.
Plain forms (no htmx)
Section titled “Plain forms (no htmx)”data-hc-confirm also works on a submit button in an ordinary
<form> — no htmx attributes required:
<form method="post" action="/items/123/delete"> <button class="hc-button" data-variant="error" data-hc-confirm="Delete this item?"> Delete </button></form>On confirm, the behavior calls form.requestSubmit(button), so the
intercepted submission completes exactly as the click would have: the
button is the submitter (its formaction/formmethod are honored)
and constraint validation runs first. On cancel, nothing is submitted.
This path applies only when neither the button nor its form carries an
htmx verb attribute (hx-get/post/put/patch/delete or the data-hx-*
variants). As soon as either does, only hc:confirmed is dispatched
and htmx owns the request — the form is never double-submitted.
Without JavaScript the same markup degrades gracefully: the form submits natively, just without the confirmation step.
Customizing the dialog
Section titled “Customizing the dialog”The message comes from data-hc-confirm. The remaining attributes are
optional:
| Attribute | Default |
|---|---|
data-hc-confirm | (required) dialog body text |
data-hc-confirm-title | Confirm |
data-hc-confirm-label | Confirm |
data-hc-cancel-label | Cancel |
data-hc-confirm-variant | source’s data-variant, else primary |
<button class="hc-button" data-variant="error" type="button" data-hc-confirm="Permanently delete invoice #123? This cannot be undone." data-hc-confirm-title="Delete invoice" data-hc-confirm-label="Delete" data-hc-cancel-label="Keep" data-hx-delete="/invoices/123" data-hx-trigger="hc:confirmed" data-hx-target="closest tr"> Delete</button>Expanded HTML
Section titled “Expanded HTML”Wrapping the button in .hc-action with an htmx indicator gives the
full destructive-action experience from plan §24:
<span class="hc-action"> <button class="hc-button" data-variant="error" type="button" data-hc-confirm="Delete this item?" data-hx-delete="/items/123" data-hx-trigger="hc:confirmed" data-hx-target="closest tr" data-hx-swap="outerHTML" data-hx-disabled-elt="this" data-hx-indicator="closest .hc-action"> Delete </button> <span class="hc-spinner htmx-indicator" aria-hidden="true"></span></span>Macro form
Section titled “Macro form”If you import @hypermedia-components/core/macros, you can write the
same pattern as a single custom element:
<hc-confirm-action method="delete" action="/items/123" target="closest tr" swap="outerHTML" variant="error" message="Delete this item?" confirm-label="Delete" cancel-label="Keep"> Delete</hc-confirm-action>The element runs once on connectedCallback and replaces its
children with the expanded HTML shown above, then calls
htmx.process(this) so htmx picks up the new attributes. The
upgrade is idempotent — re-attaching the element does not re-expand
it.
Attributes:
| Attribute | Default | Maps to |
|---|---|---|
action | (required) | URL for the htmx request |
method | post | data-hx-{method} |
target | (omitted) | data-hx-target |
swap | outerHTML | data-hx-swap |
variant | (omitted) | data-variant on the button |
message | Continue? | data-hc-confirm |
title | (omitted) | data-hc-confirm-title |
confirm-label | (omitted) | data-hc-confirm-label |
cancel-label | (omitted) | data-hc-cancel-label |
disabled-elt | this | data-hx-disabled-elt |
indicator | closest .hc-action | data-hx-indicator |
no-spinner | (boolean) | Omit .hc-spinner if set |
The macro is optional — the documented contract is the expanded HTML. If you need to step outside the macro’s attribute surface (custom button content, an extra wrapper class, …), copy the expanded HTML and edit it directly.
Interaction with htmx — the specification
Section titled “Interaction with htmx — the specification”The contract between the confirm gate and htmx, spelled out (these are browser-tested guarantees, not implementation details):
- The click is intercepted in the capture phase with
preventDefault+stopPropagation, so htmx (and any other click handler on the element) never observes the original activation. data-hx-trigger="hc:confirmed"is therefore required: only thehc:confirmedevent — dispatched on the trigger element when the user confirms — starts the request. An element withdata-hc-confirmbut a default click trigger never fires its htmx request.- Cancel dispatches nothing. No request, no event, no submission.
- A submit button of a plain form (no htmx verb attribute on the
button or its form) is submitted via
form.requestSubmit(button)afterhc:confirmed— see Plain forms. An htmx verb on either element suppresses this branch, so htmx-wired forms are never double-fired. - Do not combine with htmx’s own
hx-confirm— htmx never sees the activation, sohx-confirmnever runs; you’d be writing dead attributes. Use one mechanism per element. - The interception is delegated at the document level, so triggers swapped in by htmx are gated without re-initialization.
See also htmx integration → Confirm gating.
Hyperscript alternative
Section titled “Hyperscript alternative”If you prefer to keep the orchestration next to the markup, the same
flow can be expressed in _hyperscript
instead of relying on installConfirm(). Drop the data-hc-confirm
attribute, write the confirm step inline, and dispatch the same
hc:confirmed event that htmx already listens for:
<button class="hc-button" data-variant="error" type="button" data-hx-delete="/items/123" data-hx-trigger="hc:confirmed" data-hx-target="closest tr" data-hx-swap="outerHTML" _="on click if confirm('Delete this item?') send hc:confirmed to me end"> Delete</button>confirm(...) here is the browser’s blocking primitive, which is
unstyled and not always desirable for destructive actions. To reuse
the shared <dialog class="hc-confirm-dialog"> markup from a single
page-level element, see the
hyperscript integration page.
When to prefer this form:
- The confirm step is genuinely one-off (a single delete button in
the page chrome) and you don’t want to import
installConfirm. - You already include
_hyperscriptfor other reasons.
When to stick with vanilla:
- Several elements need the same confirm flow — one
installConfirm()call covers all of them. - You want the shared styled dialog without rendering the markup yourself.
Server response contract
Section titled “Server response contract”The server’s response is whatever the htmx attributes specify — the confirm behavior does not change the request shape.
For a row-level delete:
- On success: return HTML for the target area, or an empty body
with a status that lets htmx swap nothing. The recipe above swaps
outerHTMLof the closest<tr>, so the server returns the new row HTML (or nothing if the row is gone). - For a toast notification: include
HX-Trigger: {"hc:toast":{"message":"Deleted","variant":"success"}}in the response headers. - On validation failure: return a 4xx with HTML describing the
failure, or use
HX-Reswap/HX-Retargetto steer the swap.
Accessibility
Section titled “Accessibility”- The shared dialog uses the standard
<dialog>element opened viashowModal(). The browser handles focus trapping, Escape-to-close, andinertfor content behind the dialog. - The dialog declares
aria-labelledby(title) andaria-describedby(message), so screen readers announce both on open. - Focus moves to the Cancel button by default — destructive actions must require explicit confirmation, not a stray Enter keypress.
- On close — confirm, cancel, or Escape — the native
dialog.close()returns focus to the trigger that opened it; the behavior adds no focus code of its own. If the confirmed request then removes that trigger from the DOM (deleting a table row is the canonical case), place the next focus target deliberately in the swap: target the surrounding region and putautofocus(ortabindex="-1"plus a server-driven focus hint) on the element keyboard users should land on, so they are not silently dropped at<body>. - The original button keeps its native
<button>semantics. Use a visible, descriptive label (Delete,Discard changes) rather than an icon alone.
Progressive enhancement
Section titled “Progressive enhancement”Without the behavior loaded, the button still works:
- Without htmx, put the button inside a
<form method="post" action="/items/123/delete">— the plain-form path confirms and then submits it. An htmx-attributed button with no htmx loaded performs no action, exactly as the markup describes. - Without
hc.behaviors.js, the click bubbles normally. htmx sees nohc:confirmedevent and does nothing. Adding a<noscript>notice or rendering a fully server-side confirmation page is the recommended graceful degradation.
Related
Section titled “Related”- Button — the visual surface for the action.
- request-action recipe — the non-confirming version with spinner.
live-searchrecipe — uses the same htmx trigger model.