The Dialog component provides accessible modal dialogs built on the native <dialog> element. It supports built-in titles, an optional close button, fixed footers, server-controlled visibility, multiple sizes, and a scrollable body when content exceeds the viewport.
Import
use PUI
# or
import PUI.Dialog
Basic Usage
Dialogs require an id and can be shown/hidden programmatically:
<.button phx-click={PUI.Dialog.show_dialog("my-dialog")}>
Open Dialog
</.button>
<.dialog id="my-dialog" title="Dialog Title">
<p class="text-muted-foreground">
This is the dialog content.
</p>
<:footer>
<div class="flex justify-end gap-2">
<.button variant="outline"
phx-click={PUI.Dialog.hide_dialog("my-dialog")}>
Cancel
</.button>
<.button>Confirm</.button>
</div>
</:footer>
</.dialog>
Basic Dialog Demo
This is a demonstration of the PUI dialog component.
Dialogs are useful for confirmations, forms, and complex interactions that require user attention.
<.dialog id="demo-dialog" size="md" title="Dialog Title">
<:trigger :let={attr}>
<.button {attr}>Open Dialog</.button>
</:trigger>
<div class="space-y-4">
<p class="text-sm text-muted-foreground">
This is a demonstration of the PUI dialog component.
</p>
<p class="text-sm text-muted-foreground">
Dialogs are useful for confirmations, forms, and complex interactions that require user attention.
</p>
</div>
<:footer :let={%{hide: hide}}>
<div class="flex justify-end gap-2">
<.button variant="outline" phx-click={hide}>Cancel</.button>
<.button phx-click={hide}>Confirm</.button>
</div>
</:footer>
</.dialog>
Title and Close Button
Use title to render a built-in heading and show_close to control the header action:
<.dialog id="profile-dialog" title="Edit profile">
<p>Update your account details.</p>
<:footer>
<div class="flex justify-end gap-2">
<.button variant="outline">Cancel</.button>
<.button>Save</.button>
</div>
</:footer>
</.dialog>
<.dialog id="checkout-dialog" title="Review order" show_close={false}>
<p>Disable the close button when you need a custom action row.</p>
<:footer>
<div class="flex justify-end gap-2">
<.button variant="outline">Back</.button>
<.button>Continue</.button>
</div>
</:footer>
</.dialog>
Scrollable Body and Fixed Footer
The default dialog keeps the title and footer visible while the main content scrolls automatically:
<.dialog id="activity-dialog" title="Recent activity" size="lg">
<div class="space-y-4">
<p :for={index <- 1..12}>
Activity #{index}: This content scrolls inside the dialog body.
</p>
</div>
<:footer>
<div class="flex justify-end gap-2">
<.button variant="outline">Close</.button>
<.button>Save changes</.button>
</div>
</:footer>
</.dialog>
Scrollable Dialog Demo
Activity #1: The body scrolls while the title and footer stay visible.
Activity #2: The body scrolls while the title and footer stay visible.
Activity #3: The body scrolls while the title and footer stay visible.
Activity #4: The body scrolls while the title and footer stay visible.
Activity #5: The body scrolls while the title and footer stay visible.
Activity #6: The body scrolls while the title and footer stay visible.
Activity #7: The body scrolls while the title and footer stay visible.
Activity #8: The body scrolls while the title and footer stay visible.
Activity #9: The body scrolls while the title and footer stay visible.
Activity #10: The body scrolls while the title and footer stay visible.
Activity #11: The body scrolls while the title and footer stay visible.
Activity #12: The body scrolls while the title and footer stay visible.
Activity #13: The body scrolls while the title and footer stay visible.
Activity #14: The body scrolls while the title and footer stay visible.
Activity #15: The body scrolls while the title and footer stay visible.
Activity #16: The body scrolls while the title and footer stay visible.
Activity #17: The body scrolls while the title and footer stay visible.
Activity #18: The body scrolls while the title and footer stay visible.
Activity #19: The body scrolls while the title and footer stay visible.
Activity #20: The body scrolls while the title and footer stay visible.
Activity #21: The body scrolls while the title and footer stay visible.
Activity #22: The body scrolls while the title and footer stay visible.
Activity #23: The body scrolls while the title and footer stay visible.
Activity #24: The body scrolls while the title and footer stay visible.
Activity #25: The body scrolls while the title and footer stay visible.
Activity #26: The body scrolls while the title and footer stay visible.
Activity #27: The body scrolls while the title and footer stay visible.
Activity #28: The body scrolls while the title and footer stay visible.
Activity #29: The body scrolls while the title and footer stay visible.
Activity #30: The body scrolls while the title and footer stay visible.
<.dialog id="demo-scroll-docs" size="lg" title="Recent activity">
<:trigger :let={attr}>
<.button variant="outline" {attr}>Open scrollable dialog</.button>
</:trigger>
<div class="space-y-4">
<p :for={index <- 1..30} class="text-sm text-muted-foreground">
Activity #{index}: The body scrolls while the title and footer stay visible.
</p>
</div>
<:footer :let={%{hide: hide}}>
<div class="flex justify-end gap-2">
<.button variant="outline" phx-click={hide}>Close</.button>
<.button phx-click={hide}>Save changes</.button>
</div>
</:footer>
</.dialog>
Server-Controlled
Control dialog visibility from the server with the show attribute:
<.dialog id="server-dialog" show={@show_dialog}>
<p>This dialog is controlled by server state.</p>
<:footer>
<div class="flex justify-end gap-2">
<.button variant="outline" phx-click="close">Cancel</.button>
<.button>Continue</.button>
</div>
</:footer>
</.dialog>
def handle_event("open", _, socket) do
{:noreply, assign(socket, show_dialog: true)}
end
Sizes
Dialogs come in four sizes. For custom widths, set size="" and use class:
<.dialog id="sm-dialog" size="sm">...</.dialog>
<.dialog id="md-dialog" size="md">...</.dialog> <!-- default -->
<.dialog id="lg-dialog" size="lg">...</.dialog>
<.dialog id="xl-dialog" size="xl">...</.dialog>
<!-- Custom width via class -->
<.dialog id="wide-dialog" size="" class="max-w-[80vw] max-h-[80vh]">...</.dialog>
Dialog Sizes Demo
This is a small dialog.
This is a large dialog with more room for content.
This is an extra large dialog for complex content.
<div class="flex flex-wrap gap-3">
<.dialog id="demo-sm" size="sm" title="Small dialog">
<:trigger :let={attr}>
<.button variant="outline" {attr}>Small</.button>
</:trigger>
<p class="text-sm">This is a small dialog.</p>
<:footer :let={%{hide: hide}}>
<div class="flex justify-end">
<.button size="sm" phx-click={hide}>Close</.button>
</div>
</:footer>
</.dialog>
<.dialog id="demo-lg" size="lg" title="Large dialog">
<:trigger :let={attr}>
<.button variant="outline" {attr}>Large</.button>
</:trigger>
<p class="text-sm">This is a large dialog with more room for content.</p>
<:footer :let={%{hide: hide}}>
<div class="flex justify-end">
<.button phx-click={hide}>Close</.button>
</div>
</:footer>
</.dialog>
<.dialog id="demo-xl" size="xl" title="Extra large dialog">
<:trigger :let={attr}>
<.button variant="outline" {attr}>Extra Large</.button>
</:trigger>
<p class="text-sm">This is an extra large dialog for complex content.</p>
<:footer :let={%{hide: hide}}>
<div class="flex justify-end">
<.button phx-click={hide}>Close</.button>
</div>
</:footer>
</.dialog>
</div>
Custom Sizing
Use the class attribute with size="" to take full control over dialog dimensions:
<.dialog id="wide-dialog" size="" class="max-w-[80vw] max-h-[80vh]">
<:trigger>
<.button>Open wide dialog</.button>
</:trigger>
<p>This dialog takes 80% of the viewport.</p>
<:footer>
<.button variant="outline">Close</.button>
</:footer>
</.dialog>
Custom Size Demo
Customer Details
This dialog uses
class="max-w-[80vw] max-h-[80vh]"
to
override the default size and span 80% of the viewport in both dimensions.
Recent Orders
<.dialog
id="demo-custom-size"
size=""
class="max-w-[80vw] max-h-[80vh]"
title="Wide dialog"
>
<:trigger :let={attr}>
<.button {attr}>Open 80vw × 80vh Dialog</.button>
</:trigger>
<div class="grid gap-6 md:grid-cols-2">
<div class="space-y-4">
<h3 class="text-sm font-semibold">Customer Details</h3>
<p class="text-sm text-muted-foreground">
This dialog uses
<code class="text-xs bg-muted px-1 py-0.5 rounded">
class="max-w-[80vw] max-h-[80vh]"
</code>
to
override the default size and span 80% of the viewport in both dimensions.
</p>
<div class="space-y-2">
<div class="flex items-center gap-2 text-sm">
<span class="font-medium w-20">Name</span>
<span class="text-muted-foreground">Alexandra Chen</span>
</div>
<div class="flex items-center gap-2 text-sm">
<span class="font-medium w-20">Email</span>
<span class="text-muted-foreground">alex@example.com</span>
</div>
<div class="flex items-center gap-2 text-sm">
<span class="font-medium w-20">Plan</span>
<span class="text-muted-foreground">Enterprise</span>
</div>
</div>
</div>
<div class="space-y-4">
<h3 class="text-sm font-semibold">Recent Orders</h3>
<div class="space-y-2">
<div
:for={
%{id: id, item: item, amount: amount} <- [
%{id: "#1089", item: "Widget Pro", amount: "$249"},
%{id: "#1088", item: "Starter Kit", amount: "$89"},
%{id: "#1087", item: "Enterprise Plan", amount: "$499"},
%{id: "#1086", item: "Widget Pro", amount: "$249"},
%{id: "#1085", item: "Add-on Pack", amount: "$49"}
]
}
class="flex items-center justify-between py-1.5 border-b border-border/50 text-sm"
>
<div class="flex gap-3">
<span class="text-xs text-muted-foreground font-mono">{id}</span>
<span>{item}</span>
</div>
<span class="font-medium">{amount}</span>
</div>
</div>
</div>
</div>
<:footer :let={%{hide: hide}}>
<div class="flex justify-end gap-2">
<.button variant="outline" phx-click={hide}>Close</.button>
<.button phx-click={hide}>Save Changes</.button>
</div>
</:footer>
</.dialog>
Alert Dialog
Alert dialogs require explicit user action and cannot be dismissed by clicking the backdrop:
<.dialog id="alert-dialog" title="Delete Account?" alert={true}>
<p class="mt-2">This action cannot be undone.</p>
<:footer>
<div class="flex justify-end gap-2">
<.button variant="outline"
phx-click={PUI.Dialog.hide_dialog("alert-dialog")}>
Cancel
</.button>
<.button variant="destructive" phx-click="delete_account">
Delete
</.button>
</div>
</:footer>
</.dialog>
Alert Dialog Demo
This action cannot be undone. This will permanently delete the item.
<.dialog id="demo-alert" alert={true} size="sm" title="Are you sure?">
<:trigger :let={attr}>
<.button variant="destructive" {attr}>Delete Item</.button>
</:trigger>
<div class="space-y-3">
<p class="text-sm text-muted-foreground">
This action cannot be undone. This will permanently delete the item.
</p>
</div>
<:footer :let={%{hide: hide}}>
<div class="flex justify-end gap-2">
<.button variant="outline" phx-click={hide}>Cancel</.button>
<.button variant="destructive" phx-click={hide}>Delete</.button>
</div>
</:footer>
</.dialog>
With Trigger Slot
Use the trigger slot for inline trigger buttons:
<.dialog id="trigger-dialog">
<:trigger>
<.button>Open</.button>
</:trigger>
<p>Dialog with trigger slot.</p>
</.dialog>
Headless
Compose PUI.Dialog.Primitive when the application owns the dialog markup. The
parts keep the modal semantics, focus trap, and escape/backdrop dismissal
without any presentation classes:
<PUI.Dialog.Primitive.root id="headless" show={@show} on_cancel={JS.push("close")}>
<PUI.Dialog.Primitive.backdrop id="headless-backdrop" show={@show} class="fixed inset-0 bg-black/50" />
<PUI.Dialog.Primitive.content id="headless-content" show={@show} class="my-custom-dialog-panel">
Custom styled dialog content
</PUI.Dialog.Primitive.content>
</PUI.Dialog.Primitive.root>
backdrop/1 and content/1 default to the <root-id>-backdrop and
<root-id>-content naming convention. Pass root_id on the backdrop, and
backdrop_id/content_id on the root, when using other IDs.
API Reference
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
id |
string |
required | Unique identifier for the dialog |
show |
boolean |
false |
Server-controlled visibility |
alert |
boolean |
false |
Alert dialog mode (no backdrop dismiss) |
size |
string |
"md" |
Dialog size: "sm", "md", "lg", "xl". Set to "" for custom sizing via class |
title |
string |
nil |
Optional built-in title for the default dialog header |
show_close |
boolean |
true |
Show the built-in close button on default dialogs |
on_cancel |
JS |
%JS{} |
JS command to run on cancel |
class |
string |
"" |
Additional CSS classes applied to the content container. Use with size="" for full custom sizing (e.g. max-w-[80vw] max-h-[80vh]) |
Slots
| Name | Required | Description |
|---|---|---|
trigger |
— | Inline trigger element |
footer |
— | Optional fixed footer for actions in the default layout |
content |
— | Override the entire content container |
inner_block |
— | Main dialog body content |