Components
Dialog
The native HTML <dialog> element + a 30-line script. Focus trap, ESC-to-close, accessible modal semantics — all from the platform. shadcn-htmx adds the box styles, the close X, and click-on-backdrop-to-close.
Installation
The dialog itself is one element; the trigger / close wiring lives in public/site.js (delegated event handler — see the source).
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/dialog.json2. Use it
import { Dialog, DialogHeader, DialogTitle, DialogDescription,
DialogFooter, DialogTrigger } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
<DialogTrigger dialogFor="confirm">
<Button variant="destructive">Delete</Button>
</DialogTrigger>
<Dialog id="confirm">
<DialogHeader>
<DialogTitle>Delete item?</DialogTitle>
<DialogDescription>This cannot be undone.</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose><Button variant="outline">Cancel</Button></DialogClose>
<Button variant="destructive">Delete</Button>
</DialogFooter>
</Dialog>Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { Child, PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Dialog — shadcn-htmx, htmx v4 + Tailwind v4.
//
// We use the native HTML <dialog> element + .showModal() so the platform
// gives us:
// - Focus trap (no JS focus management required)
// - ESC to close (browser default)
// - aria-modal / role=dialog (set by showModal())
// - Focus restoration to the opener (browser default)
// - ::backdrop pseudo-element (we colour it via CSS in input.css)
//
// We add on top:
// - shadcn box styles (rounded border, shadow, centered).
// - DialogTrigger / DialogClose data attributes wired up in public/site.js.
// - Click-on-backdrop closes (also in site.js).
//
// Composition mirrors shadcn's React API:
// <Dialog id="my-dialog">
// <DialogHeader>
// <DialogTitle>...</DialogTitle>
// <DialogDescription>...</DialogDescription>
// </DialogHeader>
// <DialogBody>...form fields...</DialogBody>
// <DialogFooter>
// <DialogClose><Button variant="outline">Cancel</Button></DialogClose>
// <Button>Save</Button>
// </DialogFooter>
// </Dialog>
//
// <DialogTrigger dialogFor="my-dialog">Open</DialogTrigger>
const dialogBase =
"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 at the top layer; hide it when not open so layout
// doesn't shift.
"hidden open:grid " +
// ::backdrop styling.
"backdrop:bg-black/60 backdrop:backdrop-blur-sm"
export function dialogClasses(opts?: { class?: ClassValue }): string {
return cn(dialogBase, opts?.class)
}
type DialogProps = PropsWithChildren<{
id: string
// Set to false to disable the click-on-backdrop-closes behaviour. The
// browser-native `closedby` attribute (below) is a stronger signal — if
// you set it to "any" the browser handles backdrop dismissal natively.
closeOnBackdrop?: boolean
// Pre-open the dialog on initial render (useful for htmx swaps that return
// an already-open dialog).
open?: boolean
// Native `closedby` attribute (HTML Living Standard / WHATWG). Controls
// how the user can dismiss the dialog:
// - "any" — ESC, light dismiss (backdrop click), and code
// - "closerequest" — ESC and code only (default for showModal())
// - "none" — only code can close (e.g. terms acceptance)
// See repos/mdn/files/en-us/web/html/reference/elements/dialog/index.md:19-35
closedby?: "any" | "closerequest" | "none"
// role variant. "alertdialog" demands a synchronous user response and is
// announced by assistive tech with higher urgency. APG requires it to
// carry a description (aria-describedby).
// See repos/aria-practices/content/patterns/alertdialog/.
role?: "dialog" | "alertdialog"
// Render the X close button in the top-right corner (default true).
showCloseButton?: boolean
class?: ClassValue
ariaLabelledby?: string
ariaDescribedby?: string
}>
export function Dialog(props: DialogProps) {
const {
id,
children,
closeOnBackdrop = true,
open,
closedby,
role = "dialog",
showCloseButton = true,
class: className,
ariaLabelledby,
ariaDescribedby,
} = props
return (
<dialog
id={id}
open={open}
class={dialogClasses({ class: className })}
data-slot="dialog"
data-close-on-backdrop={closeOnBackdrop ? "true" : undefined}
// Native closedby attribute (only emitted when set so we don't override
// the browser's default of "closerequest" for showModal()).
{...(closedby ? { closedby } : {})}
// role override — Hono JSX renders the dialog with implicit role="dialog";
// we set it explicitly so consumers can switch to alertdialog.
role={role}
aria-labelledby={ariaLabelledby ?? `${id}-title`}
aria-describedby={ariaDescribedby ?? `${id}-description`}
>
{children}
{showCloseButton && (
<button
type="button"
data-dialog-close="true"
aria-label="Close"
class="absolute top-4 right-4 inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-70 transition-opacity hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
aria-hidden="true"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
)}
</dialog>
)
}
export function DialogHeader(props: PropsWithChildren<{ class?: ClassValue }>) {
return (
<div
data-slot="dialog-header"
class={cn("flex flex-col gap-1.5 text-left", props.class)}
>
{props.children}
</div>
)
}
export function DialogTitle(
props: PropsWithChildren<{ id?: string; class?: ClassValue }>,
) {
return (
<h2
id={props.id}
data-slot="dialog-title"
class={cn("text-lg leading-none font-semibold", props.class)}
>
{props.children}
</h2>
)
}
export function DialogDescription(
props: PropsWithChildren<{ id?: string; class?: ClassValue }>,
) {
return (
<p
id={props.id}
data-slot="dialog-description"
class={cn("text-sm text-muted-foreground", props.class)}
>
{props.children}
</p>
)
}
export function DialogBody(props: PropsWithChildren<{ class?: ClassValue }>) {
return (
<div
data-slot="dialog-body"
class={cn("text-sm text-foreground", props.class)}
>
{props.children}
</div>
)
}
export function DialogFooter(props: PropsWithChildren<{ class?: ClassValue }>) {
return (
<div
data-slot="dialog-footer"
class={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
props.class,
)}
>
{props.children}
</div>
)
}
// Close button wrapper — clones any single child (a Button works), attaches
// data-dialog-close so site.js can intercept the click and call .close() on
// the nearest <dialog> ancestor.
type DialogCloseProps = PropsWithChildren<{
// If true (default), the wrapped child gets data-dialog-close attached.
// Set false to attach to a non-child (e.g. when you render your own button
// here and add data-dialog-close yourself).
attachToChild?: boolean
// Native Invoker Commands mode (opt-in). When set, render a real <button>
// that closes the dialog with zero JS — the platform equivalent of
// .close()/.requestClose():
// - "close" → declarative HTMLDialogElement.close()
// - "request-close" → fires a cancelable `cancel` event first, so an
// unsaved-changes guard can preventDefault() it.
// The data-dialog-close + site.js path remains the default fallback.
// See repos/mdn/files/en-us/web/html/reference/elements/button/index.md:60-74
// repos/mdn/files/en-us/web/html/reference/elements/dialog/index.md:55-71
command?: "close" | "request-close"
// Target dialog id for the invoker button. Defaults to the closest <dialog>
// ancestor (browsers resolve commandfor up the tree), but pass it when the
// button lives outside the dialog.
commandfor?: string
// Button `value` — with the close/request-close commands the platform copies
// this into HTMLDialogElement.returnValue, so the `close` event can tell
// which control closed the dialog (e.g. "confirm" vs "cancel").
// See repos/mdn/files/en-us/web/html/reference/elements/button/index.md:149-152
value?: string
class?: ClassValue
}>
export function DialogClose(props: DialogCloseProps) {
const { children, attachToChild = true, command, commandfor, value, class: className } = props
// Native invoker mode: render a real <button> with command/commandfor so the
// browser closes the dialog (and copies `value` into returnValue) with no JS.
if (command) {
return (
<button type="button" command={command} commandfor={commandfor} value={value} class={cn(className)}>
{children}
</button>
)
}
if (!attachToChild) return <>{children}</>
// The simpler pattern: render a span with data-dialog-close="true"; the JS
// event listener walks up to find the closest <dialog>. This way we don't
// need cloneElement and the consumer can pass anything as the child.
return (
<span data-dialog-close="true" class="contents">
{children}
</span>
)
}
// Trigger button — clicks open the dialog whose id matches dialogFor.
type DialogTriggerProps = PropsWithChildren<{
dialogFor: string
class?: ClassValue
// Render mode: "wrapper" (default — wraps the child in a span so the parent
// can pass any element like a styled Button) or "button" (render a native
// <button> with the provided class).
render?: "wrapper" | "button"
type?: "button" | "submit"
id?: string
ariaHaspopup?: string
// Native Invoker Commands mode (opt-in). When true, render a real <button
// command="show-modal" commandfor={dialogFor}> that opens the dialog as a
// modal with zero JS — the declarative equivalent of .showModal(). The
// browser also wires implicit control↔dialog accessibility. The data-* +
// site.js path stays the default fallback.
// See repos/mdn/files/en-us/web/html/reference/elements/button/index.md:60-74
// repos/mdn/files/en-us/web/html/reference/elements/dialog/index.md:55-71
native?: boolean
}>
export function DialogTrigger(props: DialogTriggerProps) {
const { dialogFor, render = "wrapper", children, class: className, id, type = "button", ariaHaspopup = "dialog", native } = props
if (native) {
return (
<button
id={id}
type={type}
class={cn(className)}
command="show-modal"
commandfor={dialogFor}
aria-haspopup={ariaHaspopup}
>
{children}
</button>
)
}
if (render === "button") {
return (
<button
id={id}
type={type}
class={cn(className)}
data-dialog-trigger="true"
data-dialog-target={dialogFor}
aria-haspopup={ariaHaspopup}
>
{children}
</button>
)
}
return (
<span
data-dialog-trigger="true"
data-dialog-target={dialogFor}
class="contents"
>
{children}
</span>
)
}
1. Save the file
Copy dialog.html into templates/components/.
2. Use it
{% from "components/dialog.html" import dialog, dialog_trigger %}
{{ dialog_trigger("Delete", dialog_for="confirm",
class_="…destructive button classes…") }}
{% call dialog(id="confirm", title="Delete item?",
description="This cannot be undone.") %}
<div class="flex justify-end gap-2">
<button type="button" data-dialog-close="true">Cancel</button>
<button type="button" hx-delete="/items/42">Delete</button>
</div>
{% endcall %}View source
{# Dialog macros — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/dialog.tsx. Uses the native <dialog> element + the
wiring in public/site.js (data-dialog-trigger / data-dialog-close).
Usage:
{% from "components/dialog.html" import dialog, dialog_trigger %}
{{ dialog_trigger("Open", dialog_for="my-dialog", class_="…btn classes") }}
{% call dialog(id="my-dialog", title="Are you sure?",
description="This action cannot be undone.") %}
<!-- body content -->
<p>The item will be permanently deleted from your library.</p>
<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 …">Cancel</button>
<button type="button" class="… button default classes …"
hx-post="/items/42/delete">Delete</button>
</div>
{% endcall %} #}
{% macro dialog(
id,
title=none,
description=none,
close_on_backdrop=true,
show_close_button=true,
open=false,
closedby=none,
role="dialog",
extra_class=""
) %}
{%- 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 %}
{%- if closedby %} closedby="{{ closedby }}"{% endif %}
role="{{ role }}"
class="{{ base }} {{ extra_class }}"
data-slot="dialog"
{%- if close_on_backdrop %} data-close-on-backdrop="true"{% endif %}
aria-labelledby="{{ id }}-title"
aria-describedby="{{ id }}-description">
{%- if title or description %}
<div data-slot="dialog-header" class="flex flex-col gap-1.5 text-left">
{% if title %}<h2 id="{{ id }}-title" data-slot="dialog-title" class="text-lg leading-none font-semibold">{{ title }}</h2>{% endif %}
{% if description %}<p id="{{ id }}-description" data-slot="dialog-description" class="text-sm text-muted-foreground">{{ description }}</p>{% endif %}
</div>
{%- endif %}
{{ caller() }}
{%- if show_close_button %}
<button type="button" data-dialog-close="true" aria-label="Close"
class="absolute top-4 right-4 inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-70 transition-opacity hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
{%- endif %}
</dialog>
{% endmacro %}
{# native=true renders a native Invoker Commands button
(<button command="show-modal" commandfor="…">) that opens the dialog as a
modal with zero JS — the declarative equivalent of .showModal(). The
data-dialog-trigger + site.js path stays the default fallback.
See repos/mdn/files/en-us/web/html/reference/elements/button/index.md:60-74 #}
{% macro dialog_trigger(label, dialog_for, type="button", id=none, class_="", native=false) %}
<button {% if id %} id="{{ id }}"{% endif %}
type="{{ type }}"
class="{{ class_ }}"
{%- if native %} command="show-modal" commandfor="{{ dialog_for }}"
{%- else %} data-dialog-trigger="true" data-dialog-target="{{ dialog_for }}"{% endif %}
aria-haspopup="dialog">{{ label }}</button>
{% endmacro %}
{# Native Invoker Commands close button (opt-in). command is "close"
(declarative .close()) or "request-close" (fires a cancelable `cancel`
event first, for unsaved-changes guards). `value` is copied into the
dialog's returnValue so the close event can tell which control fired.
commandfor defaults to the closest <dialog> ancestor; pass it when the
button lives outside the dialog.
See repos/mdn/files/en-us/web/html/reference/elements/button/index.md:60-74,149-152 #}
{% macro dialog_close(label, command="close", commandfor=none, value=none, class_="") %}
<button type="button"
command="{{ command }}"
{%- if commandfor %} commandfor="{{ commandfor }}"{% endif %}
{%- if value is not none %} value="{{ value }}"{% endif %}
class="{{ class_ }}">{{ label }}</button>
{% endmacro %}
1. Save the file
Add dialog.tmpl alongside button.tmpl.
2. Use it
{{template "dialog_trigger" (dict
"Label" "Delete" "DialogFor" "confirm" "Class" "…button classes…")}}
{{template "dialog" (dict
"ID" "confirm" "Title" "Delete item?"
"Description" "This cannot be undone."
"Body" (htmlSafe `<div class="flex justify-end gap-2">
<button type="button" data-dialog-close="true">Cancel</button>
<button type="button" hx-delete="/items/42">Delete</button>
</div>`)
)}}View source
{{/*
Dialog template — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/dialog.tsx.
This template renders a complete <dialog> element. For body/footer content
you compose your own HTML and place it in .Body (rendered via "safe" raw
HTML — make sure it's trusted!) or call the template with an embedded
Define block.
Usage:
type DialogArgs struct {
ID, Title, Description string
Body template.HTML // already-rendered HTML
CloseOnBackdrop bool // default true
ShowCloseButton bool // default true
Open bool
}
tpl.ExecuteTemplate(w, "dialog", DialogArgs{
ID: "confirm-delete", Title: "Delete item?",
Description: "This cannot be undone.",
Body: template.HTML(`
<div class="flex justify-end gap-2">
<button type="button" data-dialog-close="true">Cancel</button>
<button type="button" hx-post="/items/42/delete">Delete</button>
</div>`),
})
Companion: "dialog_trigger" template (below) renders the open button.
*/}}
{{define "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" -}}
{{- $closeOnBackdrop := true -}}{{- if .CloseOnBackdropSet}}{{$closeOnBackdrop = .CloseOnBackdrop}}{{end -}}
{{- $showCloseButton := true -}}{{- if .ShowCloseButtonSet}}{{$showCloseButton = .ShowCloseButton}}{{end -}}
{{- $role := or .Role "dialog" -}}
<dialog id="{{.ID}}"
{{- if .Open}} open{{end}}
{{- if .ClosedBy}} closedby="{{.ClosedBy}}"{{end}}
role="{{$role}}"
class="{{$base}}"
data-slot="dialog"
{{- if $closeOnBackdrop}} data-close-on-backdrop="true"{{end}}
aria-labelledby="{{.ID}}-title"
aria-describedby="{{.ID}}-description">
{{- if or .Title .Description}}
<div data-slot="dialog-header" class="flex flex-col gap-1.5 text-left">
{{- if .Title}}
<h2 id="{{.ID}}-title" data-slot="dialog-title" class="text-lg leading-none font-semibold">{{.Title}}</h2>
{{- end}}
{{- if .Description}}
<p id="{{.ID}}-description" data-slot="dialog-description" class="text-sm text-muted-foreground">{{.Description}}</p>
{{- end}}
</div>
{{- end}}
{{.Body}}
{{- if $showCloseButton}}
<button type="button" data-dialog-close="true" aria-label="Close"
class="absolute top-4 right-4 inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-70 transition-opacity hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
{{- end}}
</dialog>
{{end}}
{{/*
dialog_trigger — open button. Set .Native=true to render a native Invoker
Commands button (<button command="show-modal" commandfor="…">) that opens
the dialog as a modal with zero JS — the declarative equivalent of
.showModal(). The data-dialog-trigger + site.js path stays the default.
See repos/mdn/files/en-us/web/html/reference/elements/button/index.md:60-74
*/}}
{{define "dialog_trigger"}}
<button {{if .ID}}id="{{.ID}}"{{end}}
type="{{or .Type "button"}}"
class="{{.Class}}"
{{- if .Native}} command="show-modal" commandfor="{{.DialogFor}}"
{{- else}} data-dialog-trigger="true" data-dialog-target="{{.DialogFor}}"{{end}}
aria-haspopup="dialog">{{.Label}}</button>
{{end}}
{{/*
dialog_close — native Invoker Commands close button (opt-in). .Command is
"close" (declarative .close()) or "request-close" (fires a cancelable
`cancel` event first, for unsaved-changes guards). .Value is copied into the
dialog's returnValue so the close event can tell which control fired.
.CommandFor defaults to the closest <dialog> ancestor; set it when the
button lives outside the dialog.
See repos/mdn/files/en-us/web/html/reference/elements/button/index.md:60-74,149-152
*/}}
{{define "dialog_close"}}
<button type="button"
command="{{or .Command "close"}}"
{{- if .CommandFor}} commandfor="{{.CommandFor}}"{{end}}
{{- if .Value}} value="{{.Value}}"{{end}}
class="{{.Class}}">{{.Label}}</button>
{{end}}
1. Save the file
Drop dialog.ex into lib/my_app_web/components/.
2. Use it
<.dialog_trigger dialog_for="confirm" class="…btn-classes…">
Delete
</.dialog_trigger>
<.dialog id="confirm" title="Delete item?" description="This cannot be undone.">
<div class="flex justify-end gap-2">
<button type="button" data-dialog-close="true">Cancel</button>
<button type="button" hx-delete={~p"/items/#{@item.id}"}>Delete</button>
</div>
</.dialog>View source
defmodule ShadcnHtmx.Components.Dialog do
@moduledoc """
Dialog — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Mirrors registry/ui/dialog.tsx. Renders the native <dialog> element +
attaches the data attributes that public/site.js looks for to open / close.
## Examples
<.dialog_trigger dialog_for="confirm-delete" class="…btn-classes…">
Delete item
</.dialog_trigger>
<.dialog id="confirm-delete" title="Delete item?"
description="This action cannot be undone.">
<div class="flex justify-end gap-2">
<button type="button" data-dialog-close="true">Cancel</button>
<button type="button" hx-post={~p"/items/\#{@item.id}"} hx-method="delete">Delete</button>
</div>
</.dialog>
"""
use Phoenix.Component
@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 :close_on_backdrop, :boolean, default: true
attr :show_close_button, :boolean, default: true
attr :open, :boolean, default: false
# Native HTML `closedby` attribute (HTML Living Standard).
# See repos/mdn/files/en-us/web/html/reference/elements/dialog/index.md:19-35.
attr :closedby, :string, default: nil, values: ["any", "closerequest", "none", nil]
# APG: role="alertdialog" demands a synchronous response and is announced
# with higher urgency by AT. Requires aria-describedby.
attr :role, :string, default: "dialog", values: ~w(dialog alertdialog)
attr :class, :string, default: nil
slot :inner_block, required: true
def dialog(assigns) do
assigns = assign(assigns, :base, @dialog_base)
~H"""
<dialog
id={@id}
open={@open}
closedby={@closedby}
role={@role}
class={[@base, @class]}
data-slot="dialog"
data-close-on-backdrop={@close_on_backdrop && "true"}
aria-labelledby={"#{@id}-title"}
aria-describedby={"#{@id}-description"}
>
<div :if={@title || @description} data-slot="dialog-header" class="flex flex-col gap-1.5 text-left">
<h2 :if={@title} id={"#{@id}-title"} data-slot="dialog-title" class="text-lg leading-none font-semibold">{@title}</h2>
<p :if={@description} id={"#{@id}-description"} data-slot="dialog-description" class="text-sm text-muted-foreground">{@description}</p>
</div>
{render_slot(@inner_block)}
<button
:if={@show_close_button}
type="button"
data-dialog-close="true"
aria-label="Close"
class="absolute top-4 right-4 inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-70 transition-opacity hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
</dialog>
"""
end
attr :dialog_for, :string, required: true
attr :type, :string, default: "button"
attr :class, :string, default: nil
# native: true renders a native Invoker Commands button
# (<button command="show-modal" commandfor="…">) that opens the dialog as a
# modal with zero JS — the declarative equivalent of .showModal(). The
# data-dialog-trigger + site.js path stays the default fallback.
# See repos/mdn/files/en-us/web/html/reference/elements/button/index.md:60-74.
attr :native, :boolean, default: false
attr :rest, :global
slot :inner_block, required: true
def dialog_trigger(assigns) do
~H"""
<button
type={@type}
class={@class}
command={@native && "show-modal"}
commandfor={@native && @dialog_for}
data-dialog-trigger={!@native && "true"}
data-dialog-target={!@native && @dialog_for}
aria-haspopup="dialog"
{@rest}
>
{render_slot(@inner_block)}
</button>
"""
end
# Native Invoker Commands close button (opt-in). `command` is "close"
# (declarative .close()) or "request-close" (fires a cancelable `cancel`
# event first, for unsaved-changes guards). `value` is copied into the
# dialog's returnValue so the close event can tell which control fired.
# `commandfor` defaults to the closest <dialog> ancestor; set it when the
# button lives outside the dialog.
# See repos/mdn/files/en-us/web/html/reference/elements/button/index.md:60-74,149-152.
attr :command, :string, default: "close", values: ~w(close request-close)
attr :commandfor, :string, default: nil
attr :value, :string, default: nil
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def dialog_close(assigns) do
~H"""
<button
type="button"
command={@command}
commandfor={@commandfor}
value={@value}
class={@class}
{@rest}
>
{render_slot(@inner_block)}
</button>
"""
end
end
1. Save the file
Include the open/close wiring script — see the source below.
2. Use it
<button data-dialog-trigger="true" data-dialog-target="confirm">Open</button>
<dialog id="confirm" data-close-on-backdrop="true"
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 …">
<h2>Delete item?</h2>
<p>This cannot be undone.</p>
<button data-dialog-close="true">Cancel</button>
</dialog>
<script>/* see snippets/dialog.html for the open/close wiring */</script>View source
<!--
shadcn-htmx — raw HTML dialog snippets.
Uses the native <dialog> element. The wiring (open / close / backdrop click)
is JS-driven in shadcn-htmx because <dialog> doesn't have built-in trigger
attributes; we lean on a tiny script (see public/site.js) that listens for
data-dialog-trigger / data-dialog-close clicks.
Minimal inline JS to copy alongside this snippet:
<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()
})
document.querySelectorAll('dialog[data-close-on-backdrop="true"]').forEach((d) => {
d.addEventListener('click', (e) => { if (e.target === d) d.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 dialog itself -->
<dialog id="confirm-delete"
data-slot="dialog"
data-close-on-backdrop="true"
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="dialog-header" class="flex flex-col gap-1.5 text-left">
<h2 id="confirm-delete-title" data-slot="dialog-title"
class="text-lg leading-none font-semibold">
Delete item?
</h2>
<p id="confirm-delete-description" data-slot="dialog-description"
class="text-sm text-muted-foreground">
This action cannot be undone. The item will be permanently removed.
</p>
</div>
<div data-slot="dialog-body" class="text-sm text-foreground">
You're about to delete <strong>Untitled draft</strong>.
</div>
<div data-slot="dialog-footer"
class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button type="button" data-dialog-close="true"
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>
<button type="button" data-dialog-close="true" aria-label="Close"
class="absolute top-4 right-4 inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-70 transition-opacity hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
</dialog>
<!--
Native Invoker Commands variant — ZERO JavaScript.
The platform now opens/closes <dialog> declaratively: command="show-modal"
is the equivalent of .showModal(), command="close" of .close(), and
command="request-close" fires a cancelable `cancel` event first (so an
unsaved-changes guard can preventDefault() it) before close. commandfor
points at the dialog id. A button's value is copied into the dialog's
returnValue, so the `close` event can tell which control fired.
See repos/mdn/files/en-us/web/html/reference/elements/button/index.md:60-74,149-152
repos/mdn/files/en-us/web/html/reference/elements/dialog/index.md:55-71
-->
<button type="button" command="show-modal" commandfor="confirm-delete-native"
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>
<dialog id="confirm-delete-native"
data-slot="dialog"
aria-labelledby="confirm-delete-native-title"
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="dialog-header" class="flex flex-col gap-1.5 text-left">
<h2 id="confirm-delete-native-title" data-slot="dialog-title"
class="text-lg leading-none font-semibold">Delete item?</h2>
</div>
<div data-slot="dialog-footer"
class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<!-- value="cancel" / value="confirm" land in dialog.returnValue -->
<button type="button" command="request-close" commandfor="confirm-delete-native" value="cancel"
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" command="close" commandfor="confirm-delete-native" value="confirm"
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 action — destructive variant
Click the trigger. The dialog opens, traps focus, dims the background. ESC, backdrop click, the X, or Cancel all close it.
The browser provides everything that makes a modal a modal: showModal() traps focus inside the <dialog>, ESC dispatches a cancel event then closes, and ::backdrop is a real pseudo-element you style with CSS. We only add the X button and the "clicking the backdrop closes" listener.
Try ESC, the X, the backdrop, or Cancel — all close it.
<DialogTrigger dialogFor="confirm">
<Button variant="destructive">Delete item…</Button>
</DialogTrigger>
<Dialog id="confirm">
<DialogHeader>
<DialogTitle>Delete item?</DialogTitle>
<DialogDescription>
This action cannot be undone. The item will be permanently deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose><Button variant="outline">Cancel</Button></DialogClose>
<Button variant="destructive">Delete</Button>
</DialogFooter>
</Dialog>{{ dialog_trigger("Delete item…", dialog_for="confirm",
class_="…destructive button classes…") }}
{% call dialog(id="confirm", title="Delete item?",
description="This action cannot be undone.") %}
<div class="flex justify-end gap-2">
<button type="button" data-dialog-close="true" class="…">Cancel</button>
<button type="button" hx-delete="/items/42" class="…">Delete</button>
</div>
{% endcall %}{{template "dialog_trigger" (dict
"Label" "Delete item…" "DialogFor" "confirm" "Class" "…btn classes…")}}
{{template "dialog" (dict
"ID" "confirm" "Title" "Delete item?"
"Description" "This action cannot be undone."
"Body" (htmlSafe `<div class="flex justify-end gap-2">
<button type="button" data-dialog-close="true">Cancel</button>
<button type="button" hx-delete="/items/42">Delete</button>
</div>`)
)}}<.dialog_trigger dialog_for="confirm" class="…destructive-button…">
Delete item…
</.dialog_trigger>
<.dialog id="confirm" title="Delete item?"
description="This action cannot be undone.">
<div class="flex justify-end gap-2">
<button type="button" data-dialog-close="true">Cancel</button>
<button type="button" hx-delete={~p"/items/#{@item.id}"}>Delete</button>
</div>
</.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 item…</button>
</span>
<p class="text-xs text-muted-foreground">Try ESC, the X, the backdrop, or Cancel — all close it.</p>
</div>Form inside a dialog
Inputs inherit the dialog's focus trap. Submitting the form runs the action; Esc cancels.
A dialog can host a real <form> — submit it via htmx and either close the dialog (server returns 204, you close manually) or swap content inside the dialog (server returns the updated body). When the user hits Tab inside a modal dialog, focus cycles within it; when they hit Esc, the browser fires cancel and closes.
<DialogTrigger dialogFor="edit-profile">
<Button variant="outline">Edit profile…</Button>
</DialogTrigger>
<Dialog id="edit-profile">
<DialogHeader>
<DialogTitle>Edit profile</DialogTitle>
<DialogDescription>Update your display name.</DialogDescription>
</DialogHeader>
<form hx-post="/profile" hx-target="closest dialog" hx-swap="none"
class="grid gap-3">
<Label htmlFor="name">Display name</Label>
<Input id="name" name="name" defaultValue="Mehmet" autofocus />
<DialogFooter>
<DialogClose><Button variant="outline">Cancel</Button></DialogClose>
<Button type="submit">Save</Button>
</DialogFooter>
</form>
</Dialog>{% call dialog(id="edit-profile", title="Edit profile",
description="Update your display name.") %}
<form hx-post="/profile" hx-target="closest dialog" hx-swap="none"
class="grid gap-3">
{{ label("Display name", for_="name") }}
{{ input(id="name", name="name", value="Mehmet", autofocus=true) }}
<div class="flex justify-end gap-2">
<button type="button" data-dialog-close="true">Cancel</button>
<button type="submit">Save</button>
</div>
</form>
{% endcall %}{{template "dialog" (dict
"ID" "edit-profile" "Title" "Edit profile"
"Description" "Update your display name."
"Body" (htmlSafe `<form hx-post="/profile" hx-target="closest dialog" hx-swap="none">…</form>`)
)}}<.dialog id="edit-profile" title="Edit profile"
description="Update your display name.">
<form hx-post={~p"/profile"} hx-target="closest dialog" hx-swap="none"
class="grid gap-3">
<.label for="name">Display name</.label>
<.input id="name" name="name" value={@name} autofocus />
<div class="flex justify-end gap-2">
<button type="button" data-dialog-close="true">Cancel</button>
<button type="submit">Save</button>
</div>
</form>
</.dialog><div class="flex flex-col items-center gap-3">
<span data-dialog-trigger="true" data-dialog-target="ex-form-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 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-9 px-4 py-2 has-[>svg]:px-3" data-slot="button" data-variant="outline" data-size="default">Edit profile…</button>
</span>
</div>Further reading
htmx — fetch dialog from the server
The trigger does not pre-render the dialog. It GETs HTML from the server, htmx swaps the markup into a host slot, then site.js promotes it to a modal.
Useful when the dialog needs server-side data (the editable fields, a list to pick from, a long article). hx-get fetches the HTML, hx-target="#dlg-host" drops it into a slot, and htmx:afterSwap fires our promote-to-modal handler in site.js. The dialog arrives with the open attribute, gets that stripped, and .showModal() takes over.
<Button hx-get="/api/dialog" hx-target="#dlg-host" hx-swap="innerHTML">
Fetch & open
</Button>
<div id="dlg-host" />
{/* The server returns <dialog id="…" open>…</dialog>. site.js's
htmx:afterSwap listener promotes it to .showModal(). */}<button hx-get="/api/dialog" hx-target="#dlg-host" hx-swap="innerHTML">
Fetch & open
</button>
<div id="dlg-host"></div><button hx-get="/api/dialog" hx-target="#dlg-host" hx-swap="innerHTML">
Fetch & open
</button>
<div id="dlg-host"></div><button hx-get={~p"/api/dialog"} hx-target="#dlg-host" hx-swap="innerHTML">
Fetch & open
</button>
<div id="dlg-host"></div><div class="flex flex-col items-center gap-3">
<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 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-9 px-4 py-2 has-[>svg]:px-3" data-slot="button" data-variant="outline" data-size="default" hx-get="/dialog/server-rendered" hx-target="#dlg-host" hx-swap="innerHTML">Fetch & open dialog</button>
<div id="dlg-host">
</div>
</div>Further reading
API Reference
<Dialog>
| Prop | Type | Default | Description |
|---|---|---|---|
DialogTrigger native | boolean | false | Opt-in: render a native Invoker Commands button (command="show-modal" commandfor={dialogFor}) that opens the dialog as a modal with zero JS — the declarative equivalent of .showModal(). The data-dialog-trigger + site.js path stays the default fallback.MDNbutton command/commandfor |
DialogClose command | "close"|"request-close" | — | Opt-in native invoker close button. "close" = declarative .close(); "request-close" fires a cancelable cancel event first so an unsaved-changes guard can preventDefault() it. Without this prop, DialogClose keeps its data-dialog-close + site.js behaviour.MDNbutton command |
DialogClose commandfor | string | — | Target dialog id for the native invoker close button. Defaults to the closest <dialog> ancestor; set it when the button lives outside the dialog. Only used when command is set. |
DialogClose value | string | — | Button value. With command="close"/"request-close" the platform copies it into HTMLDialogElement.returnValue, so the close event can tell which control closed the dialog (e.g. "confirm" vs "cancel").MDNbutton value -> returnValue |
id* | string | — | Used by DialogTrigger's dialogFor prop to open this dialog. |
open | boolean | false | Pre-open at initial render (for htmx-fetched dialogs). |
closeOnBackdrop | boolean | true | Click outside the dialog box closes it. |
closedby | "any"|"closerequest"|"none" | — | Native HTML attribute. "any" = ESC + backdrop, "closerequest" = ESC only, "none" = code only.MDN<dialog closedby> |
role | "dialog"|"alertdialog" | "dialog" | alertdialog announces with higher urgency; requires aria-describedby. |
showCloseButton | boolean | true | Render the X button in the top-right. |
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