Components
alert-dialog
A modal that interrupts the user to confirm a consequential action. Built on the native HTML <dialog> with role="alertdialog". Unlike Dialog it is not light-dismissible — clicking the backdrop does nothing; the user must pick Cancel or the confirming action.
Installation
Reuses the Dialog open/close wiring in public/site.js (the delegated data-dialog-trigger / data-dialog-close handler). No backdrop-click closer is attached.
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/alert-dialog.json2. Use it
import { AlertDialog, AlertDialogHeader, AlertDialogTitle,
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel,
AlertDialogAction, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
<AlertDialogTrigger dialogFor="confirm">
<Button variant="destructive">Delete</Button>
</AlertDialogTrigger>
<AlertDialog id="confirm">
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>This cannot be undone.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel><Button variant="outline" autofocus>Cancel</Button></AlertDialogCancel>
<AlertDialogAction><Button variant="destructive">Delete</Button></AlertDialogAction>
</AlertDialogFooter>
</AlertDialog>Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { Child, PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// AlertDialog — shadcn-htmx, htmx v4 + Tailwind v4.
//
// shadcn source of truth: repos/shadcn-ui/apps/v4/registry/new-york-v4/ui/
// alert-dialog.tsx (Radix AlertDialog). We mirror its anatomy
// (Trigger / Content / Header / Title / Description / Footer / Action /
// Cancel) but ship a native HTML <dialog> instead of a Radix portal.
//
// APG pattern: repos/aria-practices/content/patterns/alertdialog/
// alertdialog-pattern.html. An alert dialog is a *modal* dialog that
// interrupts the workflow to acquire a response, so APG requires:
// - role="alertdialog" (announced with higher urgency than dialog)
// - aria-modal="true" (set automatically by .showModal())
// - aria-labelledby -> title (visible label)
// - aria-describedby -> body (the alert message — REQUIRED, unlike dialog)
// See alertdialog-pattern.html:44-61.
//
// Why native <dialog> + showModal():
// - Focus trap, ESC-to-close, focus restoration, the inert backdrop and
// aria-modal all come from the platform — no JS focus management.
// (repos/mdn/files/en-us/web/api/htmldialogelement/showmodal/.)
//
// HOW IT DIFFERS FROM Dialog (registry/ui/dialog.tsx):
// - NOT light-dismissible. A modal opened with showModal() defaults to
// closedby="closerequest" (ESC + code only, NO backdrop click) per the
// HTML Living Standard — repos/mdn/files/en-us/web/html/reference/
// elements/dialog/index.md:33-35. We pin closedby="closerequest" to make
// that explicit and we do NOT emit the data-close-on-backdrop hook that
// site.js uses for Dialog, so a click on the backdrop never dismisses.
// - No X close button: APG requires an explicit Cancel / Confirm response.
// - Reuses Dialog's open/close wiring in public/site.js
// (data-dialog-trigger / data-dialog-close).
//
// Composition mirrors shadcn's React API:
// <AlertDialogTrigger dialogFor="confirm">
// <Button variant="destructive">Delete</Button>
// </AlertDialogTrigger>
//
// <AlertDialog id="confirm">
// <AlertDialogHeader>
// <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
// <AlertDialogDescription>This cannot be undone.</AlertDialogDescription>
// </AlertDialogHeader>
// <AlertDialogFooter>
// <AlertDialogCancel><Button variant="outline">Cancel</Button></AlertDialogCancel>
// <AlertDialogAction><Button variant="destructive">Delete</Button></AlertDialogAction>
// </AlertDialogFooter>
// </AlertDialog>
const alertDialogBase =
"fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 m-0 w-[calc(100%-2rem)] max-w-lg " +
"grid gap-4 rounded-lg border bg-background p-6 text-foreground shadow-lg outline-none " +
// Native <dialog> sits in the top layer; hide it when not open so layout
// doesn't shift.
"hidden open:grid " +
// ::backdrop styling — same token palette as Dialog.
"backdrop:bg-black/60 backdrop:backdrop-blur-sm"
export function alertDialogClasses(opts?: { class?: ClassValue }): string {
return cn(alertDialogBase, opts?.class)
}
type AlertDialogProps = PropsWithChildren<{
id: string
// Pre-open on initial render (useful for htmx swaps that return an
// already-open alert dialog; site.js promotes <dialog open> to .showModal()).
open?: boolean
class?: ClassValue
// APG: name the alertdialog with EITHER aria-labelledby -> a visible title OR
// aria-label when there is no visible AlertDialogTitle (e.g. a short error
// alert). See alertdialog-pattern.html:47-57. When ariaLabel is set we omit
// the auto aria-labelledby fallback so the two naming mechanisms don't collide.
ariaLabel?: string
ariaLabelledby?: string
ariaDescribedby?: string
}>
export function AlertDialog(props: AlertDialogProps) {
const {
id,
children,
open,
class: className,
ariaLabel,
ariaLabelledby,
ariaDescribedby,
} = props
return (
<dialog
id={id}
open={open}
class={alertDialogClasses({ class: className })}
data-slot="alert-dialog"
// No data-close-on-backdrop: an alert dialog must require an explicit
// Cancel / Confirm response, so a backdrop click never dismisses it.
// Pin the native closedby so light dismiss stays off even if a future
// browser changes showModal() defaults.
// See repos/mdn/.../html/reference/elements/dialog/index.md:33-35.
closedby="closerequest"
// APG: the container carries role="alertdialog"; .showModal() adds
// aria-modal="true". See alertdialog-pattern.html:44-46.
role="alertdialog"
// APG (alertdialog-pattern.html:47-57): aria-label OR aria-labelledby.
// An explicit ariaLabel wins and suppresses the id-title fallback so the
// dialog isn't named twice (and doesn't reference a missing title id).
aria-label={ariaLabel}
aria-labelledby={ariaLabel ? undefined : (ariaLabelledby ?? `${id}-title`)}
// REQUIRED by APG (alertdialog-pattern.html:58-60): the description
// refers to the element containing the alert message.
aria-describedby={ariaDescribedby ?? `${id}-description`}
>
{children}
</dialog>
)
}
export function AlertDialogHeader(
props: PropsWithChildren<{ class?: ClassValue }>,
) {
return (
<div
data-slot="alert-dialog-header"
class={cn("flex flex-col gap-1.5 text-left", props.class)}
>
{props.children}
</div>
)
}
export function AlertDialogTitle(
props: PropsWithChildren<{ id?: string; class?: ClassValue }>,
) {
return (
<h2
id={props.id}
data-slot="alert-dialog-title"
class={cn("text-lg leading-none font-semibold", props.class)}
>
{props.children}
</h2>
)
}
export function AlertDialogDescription(
props: PropsWithChildren<{ id?: string; class?: ClassValue }>,
) {
return (
<p
id={props.id}
data-slot="alert-dialog-description"
class={cn("text-sm text-muted-foreground", props.class)}
>
{props.children}
</p>
)
}
export function AlertDialogFooter(
props: PropsWithChildren<{ class?: ClassValue }>,
) {
return (
<div
data-slot="alert-dialog-footer"
class={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
props.class,
)}
>
{props.children}
</div>
)
}
// Cancel — the non-destructive response. Wraps any single child (a Button
// works) and attaches data-dialog-close so site.js calls .close() on the
// nearest <dialog>. APG recommends focusing the least-destructive action;
// authors should add `autofocus` to this button (see docs).
export function AlertDialogCancel(props: PropsWithChildren<{}>) {
return (
<span data-dialog-close="true" class="contents">
{props.children}
</span>
)
}
// Action — the confirming response. Also closes the dialog after its action
// runs (e.g. an hx-* request fires on click; data-dialog-close dismisses).
// Wrap a destructive Button for delete-style confirmations.
export function AlertDialogAction(props: PropsWithChildren<{}>) {
return (
<span data-dialog-close="true" class="contents">
{props.children}
</span>
)
}
// Trigger — clicks open the alert dialog whose id matches dialogFor. Shares
// Dialog's site.js handler (data-dialog-trigger / data-dialog-target).
type AlertDialogTriggerProps = PropsWithChildren<{
dialogFor: string
class?: ClassValue
// "wrapper" (default — wraps the child so the parent can pass a styled
// Button) or "button" (render a native <button> with the provided class).
render?: "wrapper" | "button"
type?: "button" | "submit"
id?: string
}>
export function AlertDialogTrigger(props: AlertDialogTriggerProps) {
const {
dialogFor,
render = "wrapper",
children,
class: className,
id,
type = "button",
} = props
if (render === "button") {
return (
<button
id={id}
type={type}
class={cn(className)}
data-dialog-trigger="true"
data-dialog-target={dialogFor}
aria-haspopup="dialog"
>
{children}
</button>
)
}
return (
<span
data-dialog-trigger="true"
data-dialog-target={dialogFor}
class="contents"
>
{children}
</span>
)
}
1. Save the file
Copy alert-dialog.html into templates/components/.
2. Use it
{% from "components/alert-dialog.html" import alert_dialog, alert_dialog_trigger %}
{{ alert_dialog_trigger("Delete", dialog_for="confirm",
class_="…destructive button classes…") }}
{% call alert_dialog(id="confirm", title="Are you absolutely sure?",
description="This action cannot be undone.") %}
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button type="button" data-dialog-close="true" autofocus>Cancel</button>
<button type="button" data-dialog-close="true" hx-post="/items/42">Delete</button>
</div>
{% endcall %}View source
{# AlertDialog macros — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/alert-dialog.tsx. Native <dialog> + .showModal()
(wiring in public/site.js: data-dialog-trigger / data-dialog-close).
Unlike dialog.html this is an *alert* dialog (APG alertdialog pattern):
- role="alertdialog", aria-describedby is required (the message).
- NOT light-dismissible: no data-close-on-backdrop, closedby="closerequest".
- No X button — the user must pick Cancel or the confirming action.
Usage:
{% from "components/alert-dialog.html" import alert_dialog, alert_dialog_trigger %}
{{ alert_dialog_trigger("Delete", dialog_for="confirm", class_="…btn classes") }}
{% call alert_dialog(id="confirm", title="Are you absolutely sure?",
description="This action cannot be undone.") %}
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button type="button" data-dialog-close="true"
class="… button outline classes …" autofocus>Cancel</button>
<button type="button" data-dialog-close="true"
class="… button destructive classes …"
hx-post="/items/42/delete">Delete</button>
</div>
{% endcall %} #}
{% macro alert_dialog(
id,
title=none,
description=none,
open=false,
aria_label=none
) %}
{%- set base -%}
fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 m-0 w-[calc(100%-2rem)] max-w-lg grid gap-4 rounded-lg border bg-background p-6 text-foreground shadow-lg outline-none hidden open:grid backdrop:bg-black/60 backdrop:backdrop-blur-sm
{%- endset -%}
<dialog id="{{ id }}"
{%- if open %} open{% endif %}
closedby="closerequest"
role="alertdialog"
class="{{ base }}"
data-slot="alert-dialog"
{#- APG (alertdialog-pattern.html:47-57): name via aria-label OR
aria-labelledby. An explicit aria_label suppresses the id-title
fallback so the dialog isn't named twice. #}
{%- if aria_label %} aria-label="{{ aria_label }}"
{%- else %} aria-labelledby="{{ id }}-title"{% endif %}
aria-describedby="{{ id }}-description">
{%- if title or description %}
<div data-slot="alert-dialog-header" class="flex flex-col gap-1.5 text-left">
{% if title %}<h2 id="{{ id }}-title" data-slot="alert-dialog-title" class="text-lg leading-none font-semibold">{{ title }}</h2>{% endif %}
{% if description %}<p id="{{ id }}-description" data-slot="alert-dialog-description" class="text-sm text-muted-foreground">{{ description }}</p>{% endif %}
</div>
{%- endif %}
{{ caller() }}
</dialog>
{% endmacro %}
{% macro alert_dialog_trigger(label, dialog_for, type="button", id=none, class_="") %}
<button {% if id %} id="{{ id }}"{% endif %}
type="{{ type }}"
class="{{ class_ }}"
data-dialog-trigger="true"
data-dialog-target="{{ dialog_for }}"
aria-haspopup="dialog">{{ label }}</button>
{% endmacro %}
1. Save the file
Add alert-dialog.tmpl alongside your other templates.
2. Use it
{{template "alert_dialog_trigger" (dict
"Label" "Delete" "DialogFor" "confirm" "Class" "…button classes…")}}
{{template "alert_dialog" (dict
"ID" "confirm" "Title" "Are you absolutely sure?"
"Description" "This action cannot be undone."
"Body" (htmlSafe `<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button type="button" data-dialog-close="true" autofocus>Cancel</button>
<button type="button" data-dialog-close="true" hx-post="/items/42">Delete</button>
</div>`)
)}}View source
{{/*
AlertDialog template — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/alert-dialog.tsx.
Native <dialog> + .showModal() (wiring in public/site.js:
data-dialog-trigger / data-dialog-close). Unlike "dialog" this is an
*alert* dialog (APG alertdialog pattern):
- role="alertdialog", aria-describedby is required (the message).
- NOT light-dismissible: no data-close-on-backdrop, closedby="closerequest".
- No X button — the user must pick Cancel or the confirming action.
Usage:
type AlertDialogArgs struct {
ID, Title, Description string
Body template.HTML // already-rendered footer HTML
Open bool
// APG: name the dialog via aria-label when there is no visible Title
// (alertdialog-pattern.html:47-57). Suppresses the id-title fallback.
AriaLabel string
}
tpl.ExecuteTemplate(w, "alert_dialog", map[string]any{
"ID": "confirm", "Title": "Are you absolutely sure?",
"Description": "This action cannot be undone.",
"Body": template.HTML(`
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button type="button" data-dialog-close="true" autofocus>Cancel</button>
<button type="button" data-dialog-close="true" hx-post="/items/42/delete">Delete</button>
</div>`),
})
Companion: "alert_dialog_trigger" template (below) renders the open button.
*/}}
{{define "alert_dialog"}}
{{- $base := "fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 m-0 w-[calc(100%-2rem)] max-w-lg grid gap-4 rounded-lg border bg-background p-6 text-foreground shadow-lg outline-none hidden open:grid backdrop:bg-black/60 backdrop:backdrop-blur-sm" -}}
<dialog id="{{.ID}}"
{{- if .Open}} open{{end}}
closedby="closerequest"
role="alertdialog"
class="{{$base}}"
data-slot="alert-dialog"
{{- /* APG (alertdialog-pattern.html:47-57): name via aria-label OR
aria-labelledby. An explicit .AriaLabel suppresses the id-title
fallback so the dialog isn't named twice. */}}
{{- if .AriaLabel}} aria-label="{{.AriaLabel}}"
{{- else}} aria-labelledby="{{.ID}}-title"{{end}}
aria-describedby="{{.ID}}-description">
{{- if or .Title .Description}}
<div data-slot="alert-dialog-header" class="flex flex-col gap-1.5 text-left">
{{- if .Title}}
<h2 id="{{.ID}}-title" data-slot="alert-dialog-title" class="text-lg leading-none font-semibold">{{.Title}}</h2>
{{- end}}
{{- if .Description}}
<p id="{{.ID}}-description" data-slot="alert-dialog-description" class="text-sm text-muted-foreground">{{.Description}}</p>
{{- end}}
</div>
{{- end}}
{{.Body}}
</dialog>
{{end}}
{{define "alert_dialog_trigger"}}
<button {{if .ID}}id="{{.ID}}"{{end}}
type="{{or .Type "button"}}"
class="{{.Class}}"
data-dialog-trigger="true"
data-dialog-target="{{.DialogFor}}"
aria-haspopup="dialog">{{.Label}}</button>
{{end}}
1. Save the file
Drop alert_dialog.ex into lib/my_app_web/components/.
2. Use it
<.alert_dialog_trigger dialog_for="confirm" class="…destructive-btn…">
Delete
</.alert_dialog_trigger>
<.alert_dialog id="confirm" title="Are you absolutely sure?"
description="This action cannot be undone.">
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button type="button" data-dialog-close="true" autofocus>Cancel</button>
<button type="button" data-dialog-close="true"
hx-post={~p"/items/#{@item.id}"}>Delete</button>
</div>
</.alert_dialog>View source
defmodule ShadcnHtmx.Components.AlertDialog do
@moduledoc """
AlertDialog — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Mirrors registry/ui/alert-dialog.tsx. Renders the native <dialog> element +
the data attributes public/site.js looks for to open / close
(data-dialog-trigger / data-dialog-close, shared with the Dialog component).
Unlike `Dialog`, this is an *alert* dialog
(repos/aria-practices/content/patterns/alertdialog/alertdialog-pattern.html):
- role="alertdialog", aria-describedby is required (the alert message).
- NOT light-dismissible: no data-close-on-backdrop, closedby="closerequest"
(the native default for showModal() — see
repos/mdn/files/en-us/web/html/reference/elements/dialog/index.md:33-35).
- No X button — the user must choose Cancel or the confirming action.
## Examples
<.alert_dialog_trigger dialog_for="confirm" class="…btn-classes…">
Delete item
</.alert_dialog_trigger>
<.alert_dialog id="confirm" title="Are you absolutely sure?"
description="This action cannot be undone.">
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button type="button" data-dialog-close="true" autofocus>Cancel</button>
<button type="button" data-dialog-close="true"
hx-post={~p"/items/\#{@item.id}"} hx-method="delete">Delete</button>
</div>
</.alert_dialog>
"""
use Phoenix.Component
@alert_dialog_base "fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 m-0 w-[calc(100%-2rem)] max-w-lg " <>
"grid gap-4 rounded-lg border bg-background p-6 text-foreground shadow-lg outline-none " <>
"hidden open:grid " <>
"backdrop:bg-black/60 backdrop:backdrop-blur-sm"
attr :id, :string, required: true
attr :title, :string, default: nil
attr :description, :string, default: nil
attr :open, :boolean, default: false
attr :class, :string, default: nil
# APG: name the alertdialog with aria-label when there is no visible title
# (alertdialog-pattern.html:47-57). When set, it suppresses the id-title
# aria-labelledby fallback so the dialog isn't named twice.
attr :aria_label, :string, default: nil
slot :inner_block, required: true
def alert_dialog(assigns) do
assigns = assign(assigns, :base, @alert_dialog_base)
~H"""
<dialog
id={@id}
open={@open}
closedby="closerequest"
role="alertdialog"
class={[@base, @class]}
data-slot="alert-dialog"
aria-label={@aria_label}
aria-labelledby={if @aria_label, do: nil, else: "#{@id}-title"}
aria-describedby={"#{@id}-description"}
>
<div :if={@title || @description} data-slot="alert-dialog-header" class="flex flex-col gap-1.5 text-left">
<h2 :if={@title} id={"#{@id}-title"} data-slot="alert-dialog-title" class="text-lg leading-none font-semibold">{@title}</h2>
<p :if={@description} id={"#{@id}-description"} data-slot="alert-dialog-description" class="text-sm text-muted-foreground">{@description}</p>
</div>
{render_slot(@inner_block)}
</dialog>
"""
end
attr :dialog_for, :string, required: true
attr :type, :string, default: "button"
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def alert_dialog_trigger(assigns) do
~H"""
<button
type={@type}
class={@class}
data-dialog-trigger="true"
data-dialog-target={@dialog_for}
aria-haspopup="dialog"
{@rest}
>
{render_slot(@inner_block)}
</button>
"""
end
end
1. Save the file
Paste the markup; it relies only on the theme tokens in styles.css.
2. Use it
<button data-dialog-trigger="true" data-dialog-target="confirm"
aria-haspopup="dialog">Delete</button>
<dialog id="confirm" closedby="closerequest" role="alertdialog"
aria-labelledby="confirm-title" aria-describedby="confirm-description"
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 …">
<h2 id="confirm-title">Are you absolutely sure?</h2>
<p id="confirm-description">This action cannot be undone.</p>
<button data-dialog-close="true" autofocus>Cancel</button>
<button data-dialog-close="true">Delete</button>
</dialog>
<script>/* open/close wiring — see snippets/alert-dialog.html */</script>View source
<!--
shadcn-htmx — raw HTML alert-dialog snippet.
Native <dialog> + .showModal(). The open/close wiring is JS-driven because
<dialog> has no built-in trigger attributes; a tiny script (see public/site.js
in the docs, or the inline snippet below) listens for data-dialog-trigger /
data-dialog-close clicks.
This is an *alert* dialog (APG alertdialog pattern):
- role="alertdialog", aria-describedby is required (the alert message).
- NOT light-dismissible: no data-close-on-backdrop, closedby="closerequest".
A modal opened with showModal() does not close on a backdrop click; the
user must choose Cancel or the confirming action.
- No X close button.
Minimal inline JS to copy alongside this snippet (open + button-close only —
note there is NO backdrop-click closer for alert dialogs):
<script>
document.addEventListener('click', (e) => {
const t = e.target.closest('[data-dialog-trigger]')
if (t) document.getElementById(t.dataset.dialogTarget)?.showModal()
const c = e.target.closest('[data-dialog-close]')
if (c) c.closest('dialog')?.close()
})
</script>
-->
<!-- Trigger button -->
<button type="button"
data-dialog-trigger="true"
data-dialog-target="confirm-delete"
aria-haspopup="dialog"
class="inline-flex h-9 items-center justify-center rounded-md bg-destructive px-4 text-sm font-medium text-white hover:bg-destructive/90">
Delete item
</button>
<!-- The alert dialog itself -->
<!-- This dialog has a visible title, so it is named via aria-labelledby.
APG (alertdialog-pattern.html:47-57): if you DROP the AlertDialogTitle
(e.g. a short error alert), name the dialog with aria-label="…" instead
and remove aria-labelledby so it isn't named twice / left dangling. -->
<dialog id="confirm-delete"
closedby="closerequest"
role="alertdialog"
data-slot="alert-dialog"
aria-labelledby="confirm-delete-title"
aria-describedby="confirm-delete-description"
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 m-0 w-[calc(100%-2rem)] max-w-lg grid gap-4 rounded-lg border bg-background p-6 text-foreground shadow-lg outline-none hidden open:grid backdrop:bg-black/60 backdrop:backdrop-blur-sm">
<div data-slot="alert-dialog-header" class="flex flex-col gap-1.5 text-left">
<h2 id="confirm-delete-title" data-slot="alert-dialog-title"
class="text-lg leading-none font-semibold">
Are you absolutely sure?
</h2>
<p id="confirm-delete-description" data-slot="alert-dialog-description"
class="text-sm text-muted-foreground">
This action cannot be undone. This will permanently delete the item and
remove its data from our servers.
</p>
</div>
<div data-slot="alert-dialog-footer"
class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button type="button" data-dialog-close="true" autofocus
class="inline-flex h-9 items-center justify-center rounded-md border bg-background px-4 text-sm font-medium shadow-xs hover:bg-accent">
Cancel
</button>
<button type="button"
hx-post="/items/42" hx-target="closest dialog" hx-swap="none"
data-dialog-close="true"
class="inline-flex h-9 items-center justify-center rounded-md bg-destructive px-4 text-sm font-medium text-white hover:bg-destructive/90">
Delete
</button>
</div>
</dialog>
Examples
Confirm a destructive action
Click the trigger. The alert dialog traps focus and dims the background. ESC and Cancel close it — a backdrop click does NOT.
An alertdialog is a modal dialog that demands a response. The browser's showModal() gives us the focus trap, ESC handling and aria-modal; we set role="alertdialog" plus the required aria-describedby. We pin closedby="closerequest" so the backdrop is inert — the user must choose Cancel or Delete. APG recommends focusing the least-destructive action, so Cancel carries autofocus.
ESC or Cancel close it. The backdrop does nothing.
<AlertDialogTrigger dialogFor="confirm">
<Button variant="destructive">Delete account…</Button>
</AlertDialogTrigger>
<AlertDialog id="confirm">
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This permanently deletes your account and removes your data.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel><Button variant="outline" autofocus>Cancel</Button></AlertDialogCancel>
<AlertDialogAction><Button variant="destructive">Delete</Button></AlertDialogAction>
</AlertDialogFooter>
</AlertDialog>{{ alert_dialog_trigger("Delete account…", dialog_for="confirm",
class_="…destructive button classes…") }}
{% call alert_dialog(id="confirm", title="Are you absolutely sure?",
description="This permanently deletes your account.") %}
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button type="button" data-dialog-close="true" autofocus class="…">Cancel</button>
<button type="button" data-dialog-close="true" class="…">Delete</button>
</div>
{% endcall %}{{template "alert_dialog_trigger" (dict
"Label" "Delete account…" "DialogFor" "confirm" "Class" "…btn classes…")}}
{{template "alert_dialog" (dict
"ID" "confirm" "Title" "Are you absolutely sure?"
"Description" "This permanently deletes your account."
"Body" (htmlSafe `<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button type="button" data-dialog-close="true" autofocus>Cancel</button>
<button type="button" data-dialog-close="true">Delete</button>
</div>`)
)}}<.alert_dialog_trigger dialog_for="confirm" class="…destructive-button…">
Delete account…
</.alert_dialog_trigger>
<.alert_dialog id="confirm" title="Are you absolutely sure?"
description="This permanently deletes your account.">
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button type="button" data-dialog-close="true" autofocus>Cancel</button>
<button type="button" data-dialog-close="true">Delete</button>
</div>
</.alert_dialog><div class="flex flex-col items-center gap-3">
<span data-dialog-trigger="true" data-dialog-target="ex-confirm-dlg" class="contents">
<button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 h-9 px-4 py-2 has-[>svg]:px-3" data-slot="button" data-variant="destructive" data-size="default">Delete account…</button>
</span>
<p class="text-xs text-muted-foreground">ESC or Cancel close it. The backdrop does nothing.</p>
</div>htmx — confirm, then mutate
The confirming action fires an htmx request and closes the dialog. Cancel just closes; no request is sent.
The confirming button is a real <button> carrying both hx-post and data-dialog-close: htmx sends the request and the shared site.js handler calls .close() on the dialog. Because there is no backdrop dismissal, a user can't accidentally miss the prompt by clicking away.
<AlertDialogTrigger dialogFor="discard">
<Button variant="destructive">Discard draft…</Button>
</AlertDialogTrigger>
<AlertDialog id="discard">
<AlertDialogHeader>
<AlertDialogTitle>Discard this draft?</AlertDialogTitle>
<AlertDialogDescription>
Your unsaved changes will be lost. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel><Button variant="outline" autofocus>Keep editing</Button></AlertDialogCancel>
<AlertDialogAction>
<Button variant="destructive"
hx-post="/drafts/42/discard" hx-target="#status" hx-swap="innerHTML">
Discard
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialog>{% call alert_dialog(id="discard", title="Discard this draft?",
description="Your unsaved changes will be lost.") %}
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button type="button" data-dialog-close="true" autofocus>Keep editing</button>
<button type="button" data-dialog-close="true"
hx-post="/drafts/42/discard" hx-target="#status" hx-swap="innerHTML">
Discard
</button>
</div>
{% endcall %}{{template "alert_dialog" (dict
"ID" "discard" "Title" "Discard this draft?"
"Description" "Your unsaved changes will be lost."
"Body" (htmlSafe `<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button type="button" data-dialog-close="true" autofocus>Keep editing</button>
<button type="button" data-dialog-close="true"
hx-post="/drafts/42/discard" hx-target="#status" hx-swap="innerHTML">Discard</button>
</div>`)
)}}<.alert_dialog id="discard" title="Discard this draft?"
description="Your unsaved changes will be lost.">
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button type="button" data-dialog-close="true" autofocus>Keep editing</button>
<button type="button" data-dialog-close="true"
hx-post={~p"/drafts/#{@id}/discard"} hx-target="#status" hx-swap="innerHTML">
Discard
</button>
</div>
</.alert_dialog><div class="flex flex-col items-center gap-3">
<span data-dialog-trigger="true" data-dialog-target="ex-htmx-dlg" class="contents">
<button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 h-9 px-4 py-2 has-[>svg]:px-3" data-slot="button" data-variant="destructive" data-size="default">Discard draft…</button>
</span>
<div id="ad-htmx-status" class="text-xs text-muted-foreground">No action taken yet.</div>
</div>Further reading
API Reference
<AlertDialog>
| Prop | Type | Default | Description |
|---|---|---|---|
id* | string | — | Used by AlertDialogTrigger's dialogFor prop to open this alert dialog. Also seeds the title/description ids (`{id}-title`, `{id}-description`). |
open | boolean | false | Pre-open at initial render (for htmx-fetched alert dialogs; site.js promotes <dialog open> to .showModal()). |
closedby | "closerequest" | "closerequest" | Pinned by the component. ESC + code only — never a backdrop click — so an alert dialog always requires an explicit response.MDN<dialog closedby> |
role | "alertdialog" | "alertdialog" | Fixed by the component. Announced with higher urgency than a plain dialog and requires aria-describedby.APGAlert and Message Dialogs |
ariaLabel | string | — | Accessible name when no visible <label>.MDNaria-label |
ariaLabelledby | string | — | Id of a visible element providing the accessible name.MDNaria-labelledby |
class | string | — | Extra Tailwind classes appended to the root element. |
* required