shshadcn-htmx

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.json

2. Use it

components/ui/alert-dialog.tsx
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
components/ui/alert-dialog.tsx
/** @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

templates/components/alert-dialog.html
{% 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
templates/components/alert-dialog.html
{# 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

components/alert-dialog.tmpl
{{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
components/alert-dialog.tmpl
{{/*
  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

lib/my_app_web/components/alert_dialog.ex
<.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
lib/my_app_web/components/alert_dialog.ex
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

snippets/alert-dialog.html
<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
snippets/alert-dialog.html
<!--
  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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.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-[&gt;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.

No action taken yet.
<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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.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-[&gt;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>

API Reference

<AlertDialog>

PropTypeDefaultDescription
id*string
Used by AlertDialogTrigger's dialogFor prop to open this alert dialog. Also seeds the title/description ids (`{id}-title`, `{id}-description`).
openbooleanfalse
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
ariaLabelstring
Accessible name when no visible <label>.MDNaria-label
ariaLabelledbystring
Id of a visible element providing the accessible name.MDNaria-labelledby
classstring
Extra Tailwind classes appended to the root element.

* required