A window overlaid on either the primary window or another dialog window, rendering the content underneath inert. Headless dialog primitive—pair with your own styles (or Themes Dialog) for the visual layer.
Edit profile
Make changes to your profile here. Click save when you're done.
<Dialog.root id="dialog-primitive">
<Dialog.trigger
id="dialog-trigger"
class="DemoButton violet"
>
Edit profile
</Dialog.trigger>
<Dialog.portal id="dialog-portal">
<Dialog.overlay class="DemoDialogOverlay" />
<Dialog.content
id="dialog-content"
class="DemoDialogContent"
>
<Dialog.title class="DemoDialogTitle">Edit profile</Dialog.title>
<Dialog.description class="DemoDialogDescription">
Make changes to your profile here. Click save when you're done.
</Dialog.description>
<fieldset class="DemoFieldset">
<label
class="DemoLabel"
for="dialog-name"
>
Name
</label>
<input
class="DemoInput"
id="dialog-name"
value="Pedro Duarte"
/>
</fieldset>
<fieldset class="DemoFieldset">
<label
class="DemoLabel"
for="dialog-username"
>
Username
</label>
<input
class="DemoInput"
id="dialog-username"
value="@peduarte"
/>
</fieldset>
<div style="display: flex; margin-top: 25px; justify-content: flex-end;">
<Dialog.close class="DemoButton green">Save changes</Dialog.close>
</div>
<Dialog.close
class="DemoIconButton"
aria-label="Close"
>
×
</Dialog.close>
</Dialog.content>
</Dialog.portal>
</Dialog.root>
/* reset */
button,
fieldset,
input {
all: unset;
}
.DemoDialogOverlay {
background-color: var(--black-a9);
position: fixed;
inset: 0;
animation: overlayShow 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
.DemoDialogContent {
background-color: var(--gray-1);
border-radius: 6px;
box-shadow: var(--shadow-6);
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90vw;
max-width: 500px;
max-height: 85vh;
padding: 25px;
animation: contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
.DemoDialogContent:focus {
outline: none;
}
.DemoDialogTitle {
margin: 0;
font-weight: 500;
color: var(--mauve-12);
font-size: 17px;
}
.DemoDialogDescription {
margin: 10px 0 20px;
color: var(--mauve-11);
font-size: 15px;
line-height: 1.5;
}
.DemoButton {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
padding: 0 15px;
font-size: 15px;
line-height: 1;
font-weight: 500;
height: 35px;
user-select: none;
}
.DemoButton:focus:not(:focus-visible) {
outline: 0;
}
.DemoButton:focus-visible {
outline: 2px solid var(--violet-6);
outline-offset: 1px;
}
.DemoButton.violet {
background-color: var(--violet-4);
color: var(--violet-12);
outline-color: var(--violet-6);
}
.DemoButton.violet:hover {
background-color: var(--mauve-3);
}
.DemoButton.green {
background-color: var(--green-4);
color: var(--green-11);
outline-color: var(--green-7);
}
.DemoButton.green:hover {
background-color: var(--green-5);
}
.DemoIconButton {
all: unset;
font-family: inherit;
border-radius: 100%;
height: 25px;
width: 25px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--violet-11);
position: absolute;
top: 10px;
right: 10px;
background-color: var(--gray-3);
}
.DemoIconButton:hover {
background-color: var(--violet-4);
}
.DemoIconButton:focus {
box-shadow: 0 0 0 2px var(--violet-7);
}
.DemoFieldset {
display: flex;
gap: 20px;
align-items: center;
margin-bottom: 15px;
}
.DemoLabel {
font-size: 15px;
color: var(--violet-11);
width: 90px;
text-align: right;
}
.DemoInput {
width: 100%;
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
padding: 0 10px;
font-size: 15px;
line-height: 1;
color: var(--violet-11);
box-shadow: 0 0 0 1px var(--violet-7);
height: 35px;
}
.DemoInput:focus {
box-shadow: 0 0 0 2px var(--violet-8);
}
@keyframes overlayShow {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes contentShow {
from {
opacity: 0;
transform: translate(-50%, -48%) scale(0.96);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
Anatomy
Import all parts and piece them together.
<Dialog.root>
<Dialog.trigger />
<Dialog.portal>
<Dialog.overlay />
<Dialog.content>
<Dialog.title />
<Dialog.description />
<Dialog.close />
</Dialog.content>
</Dialog.portal>
</Dialog.root>
Anatomy
-
Root - Contains all dialog parts.
-
Trigger - The button that opens the dialog.
-
Portal - Portals overlay and content into the document body.
-
Overlay - Dimmed layer behind the content.
-
Content - Contains title, description, and actions.
-
Title - Accessible title announced on open.
-
Description - Optional accessible description.
-
Close - Control that closes the dialog.
API Reference
Root
Contains all the parts of a dialog.
Use open with on_open_change for controlled open state in LiveView:
<Dialog.root id="profile-dialog" open={@open} on_open_change="dialog_open_change">
…
</Dialog.root>
def handle_event("dialog_open_change", %{"open" => open}, socket) do
{:noreply, assign(socket, :open, open)}
end
| Prop | Type | Default | Description |
|---|---|---|---|
default_open
|
boolean
|
false
|
Initial open state when uncontrolled. |
id
|
string
|
—
|
Unique id for the dialog root. |
modal
|
boolean
|
true
|
When true, outside interaction is disabled and focus is trapped. |
on_open_change
|
string
|
nil
|
LiveView event name pushed when open state changes. |
open
|
boolean
|
false
|
Controlled open state. |
default_open
Initial open state when uncontrolled.
boolean
Default
false
id
Unique id for the dialog root.
string
Default
—
modal
When true, outside interaction is disabled and focus is trapped.
boolean
Default
true
on_open_change
LiveView event name pushed when open state changes.
string
Default
nil
open
Controlled open state.
boolean
Default
false
Data attributes
| Attribute | Values | Description |
|---|---|---|
[data-state]
|
open | closed
|
Present on the root reflecting open state. |
Trigger
The button that opens the dialog.
| Prop | Type | Default | Description |
|---|---|---|---|
id
|
string
|
nil
|
Optional id for the trigger button. |
id
Optional id for the trigger button.
string
Default
nil
Data attributes
| Attribute | Values | Description |
|---|---|---|
[data-state]
|
open | closed
|
Reflects whether the dialog is open. |
Portal
Portals your overlay and content parts into the target (default body).
| Prop | Type | Default | Description |
|---|---|---|---|
id
|
string
|
—
|
Portal id required by Phoenix.Component.portal. |
target
|
string
|
"body"
|
CSS selector for the portal target (default body). |
id
Portal id required by Phoenix.Component.portal.
string
Default
—
target
CSS selector for the portal target (default body).
string
Default
"body"
Overlay
A layer that covers the inert portion of the view when the dialog is open.
| Prop | Type | Default | Description |
|---|---|---|---|
id
|
string
|
nil
|
id
string
Default
nil
Data attributes
| Attribute | Values | Description |
|---|---|---|
[data-state]
|
open | closed
|
Reflects whether the dialog is open. |
Content
Contains content to be rendered in the open dialog.
| Prop | Type | Default | Description |
|---|---|---|---|
id
|
string
|
nil
|
id
string
Default
nil
Data attributes
| Attribute | Values | Description |
|---|---|---|
[data-state]
|
open | closed
|
Reflects whether the dialog is open. |
Title
An accessible title announced when the dialog is opened. Hide with Visually Hidden if needed—still provide a title for accessibility.
| Prop | Type | Default | Description |
|---|---|---|---|
id
|
string
|
nil
|
id
string
Default
nil
Description
An optional accessible description announced when the dialog is opened.
| Prop | Type | Default | Description |
|---|---|---|---|
id
|
string
|
nil
|
id
string
Default
nil
Close
The button that closes the dialog.
| Prop | Type | Default | Description |
|---|---|---|---|
id
|
string
|
nil
|
id
string
Default
nil
Examples
Close after async action
Close from LiveView by setting controlled open={false} after a successful event, or use Dialog.close inside the content for immediate dismiss.
<Dialog.root id="async-dialog" open={@open} on_open_change="dialog_open_change">
<Dialog.trigger id="async-dialog-trigger" class="DemoButton">Edit</Dialog.trigger>
<Dialog.portal id="async-dialog-portal">
<Dialog.overlay class="DemoDialogOverlay" />
<Dialog.content id="async-dialog-content" class="DemoDialogContent">
<Dialog.title>Edit</Dialog.title>
<form phx-submit="save">
…
<Dialog.close class="DemoButton">Cancel</Dialog.close>
<button type="submit" class="DemoButton green">Save</button>
</form>
</Dialog.content>
</Dialog.portal>
</Dialog.root>
Scrollable overlay
Make the overlay the scroll container so long content scrolls within the viewport:
.DemoDialogOverlay {
overflow-y: auto;
display: grid;
place-items: center;
}
Custom portal target
Portal into a specific container via target:
<Dialog.portal id="dialog-portal" target="#app-portal-root">
…
</Dialog.portal>
Accessibility
Adheres to the Dialog WAI-ARIA design pattern.
Keyboard Interactions
| Key | Description |
|---|---|
Space
|
Opens/closes the dialog when focus is on the trigger or close control. |
Enter
|
Opens/closes the dialog when focus is on the trigger or close control. |
Tab
|
Moves focus to the next focusable element inside the dialog. |
Shift + Tab
|
Moves focus to the previous focusable element inside the dialog. |
Escape
|
Closes the dialog and returns focus to the trigger. |
Custom APIs
Create your own API by wrapping the primitive parts into a friendlier abstraction for your design system.
Abstract the overlay and the close button
This example abstracts Dialog.overlay and Dialog.close into a reusable confirm dialog.
Usage
<.confirm_dialog id="delete-confirm" title="Are you sure?">
This action cannot be undone.
</.confirm_dialog>
Implementation
def confirm_dialog(assigns) do
~H"""
<Dialog.root id={@id}>
<Dialog.trigger id={"#{@id}-trigger"} class="DemoButton">
{render_slot(@trigger) || "Open"}
</Dialog.trigger>
<Dialog.portal id={"#{@id}-portal"}>
<Dialog.overlay class="DemoDialogOverlay" />
<Dialog.content id={"#{@id}-content"} class="DemoDialogContent">
<Dialog.title class="DemoDialogTitle">{@title}</Dialog.title>
<Dialog.description class="DemoDialogDescription">
{render_slot(@inner_block)}
</Dialog.description>
<Dialog.close class="DemoButton">Cancel</Dialog.close>
<Dialog.close class="DemoButton violet">Confirm</Dialog.close>
</Dialog.content>
</Dialog.portal>
</Dialog.root>
"""
end