Modal
NewOverview
A centered modal dialog for focused forms, confirmations, and detail views, with native focus management and LiveView control.
Modal uses the native HTML
dialog
element for focus trapping, background inertness, top-layer rendering, and focus restoration. Use Modal for centered, focused work and Drawer for panels attached to a viewport edge.
The DizzyModal
hook handles the scale and fade transition, local show and hide commands, optional dismissal rules, and synchronization with the
open
attribute. Native dialog behavior continues to provide the modal accessibility model.
Use Modal.show/1
and Modal.hide/1
in Phoenix JS command pipelines to open and close the modal without a server round trip.
<div class="flex justify-center">
<Button.button kind={:outline} phx-click={Modal.show("example-modal")}>
Open modal
</Button.button>
<Modal.modal id="example-modal">
<:title>Project details</:title>
<:description>Review the current owner and status before continuing.</:description>
<dl class="grid gap-4 text-sm">
<div class="grid grid-cols-[6rem_1fr] gap-3">
<dt class="text-muted-foreground">Project</dt>
<dd class="font-medium">Component library</dd>
</div>
<div class="grid grid-cols-[6rem_1fr] gap-3">
<dt class="text-muted-foreground">Owner</dt>
<dd class="font-medium">Alex Morgan</dd>
</div>
<div class="grid grid-cols-[6rem_1fr] gap-3">
<dt class="text-muted-foreground">Status</dt>
<dd class="font-medium">In progress</dd>
</div>
</dl>
<:footer>
<Button.button kind={:ghost} phx-click={Modal.hide("example-modal")}>Cancel</Button.button>
<Button.button phx-click={Modal.hide("example-modal")}>Continue</Button.button>
</:footer>
</Modal.modal>
</div>
Sizing
The default maximum width is max-w-lg. Pass class
to replace it with a Tailwind maximum width suited to the content.
<div class="flex flex-wrap justify-center gap-2">
<div :for={{label, id, width} <- [
{"Compact", "compact", "max-w-sm"},
{"Default", "default", nil},
{"Wide", "wide", "max-w-2xl"}
]}>
<Button.button kind={:outline} phx-click={Modal.show("#{id}-modal")}>
{label}
</Button.button>
<Modal.modal id={"#{id}-modal"} class={width}>
<:title>{label} modal</:title>
<:description>The content width uses {width || "the default max-w-lg"}.</:description>
<p class="text-sm leading-6 text-muted-foreground">
Choose a maximum width that keeps forms and supporting text easy to scan.
</p>
</Modal.modal>
</div>
</div>
Escape dismissal
Set close_on_escape=false
when Escape must not interrupt the current workflow. The top-right close button, backdrop, and explicit actions still dismiss the modal.
<div class="flex justify-center">
<Button.button kind={:outline} phx-click={Modal.show("escape-disabled-modal")}>
Open Escape-protected modal
</Button.button>
<Modal.modal id="escape-disabled-modal" close_on_escape={false}>
<:title>Escape dismissal disabled</:title>
<:description>Pressing Escape does not dismiss this modal.</:description>
<p class="text-sm leading-6 text-muted-foreground">
Use the top-right close button, click the backdrop, or choose the action below instead.
</p>
<:footer>
<Button.button phx-click={Modal.hide("escape-disabled-modal")}>Dismiss explicitly</Button.button>
</:footer>
</Modal.modal>
</div>
Hidden close button
Set show_close=false
to omit the top-right close button when the footer or content supplies the dismissal control. Escape and backdrop dismissal remain available unless disabled separately.
<div class="flex justify-center">
<Button.button kind={:outline} phx-click={Modal.show("hidden-close-modal")}>
Open modal without close button
</Button.button>
<Modal.modal id="hidden-close-modal" show_close={false}>
<:title>Top-right close button hidden</:title>
<:description>The modal does not render its default close button.</:description>
<p class="text-sm leading-6 text-muted-foreground">
Escape, the backdrop, and the footer action still dismiss the modal.
</p>
<:footer>
<Button.button phx-click={Modal.hide("hidden-close-modal")}>Done</Button.button>
</:footer>
</Modal.modal>
</div>
Close on click outside
Set close_on_backdrop=false
when clicks outside the modal must not dismiss it. Escape, the top-right close button, and explicit actions remain available.
<div class="flex justify-center">
<Button.button kind={:outline} phx-click={Modal.show("outside-click-disabled-modal")}>
Open outside-click protected modal
</Button.button>
<Modal.modal id="outside-click-disabled-modal" close_on_backdrop={false}>
<:title>Outside-click dismissal disabled</:title>
<:description>Clicking outside does not dismiss this modal.</:description>
<p class="text-sm leading-6 text-muted-foreground">
Use Escape, the top-right close button, or the action below to dismiss it.
</p>
<:footer>
<Button.button phx-click={Modal.hide("outside-click-disabled-modal")}>
Close protected modal
</Button.button>
</:footer>
</Modal.modal>
</div>
LiveView control
Bind open
to an assign for server-controlled visibility. Use on_close
to update that assign after the close button, Escape key, or backdrop dismisses the modal locally.
def mount(_params, _session, socket) do
{:ok, assign(socket, :modal_open, false)}
end
def handle_event("open-liveview-modal", _params, socket) do
{:noreply, assign(socket, :modal_open, true)}
end
def handle_event("close-liveview-modal", _params, socket) do
{:noreply, assign(socket, :modal_open, false)}
end
<div class="flex justify-center">
<Button.button phx-click="open-liveview-modal">Open from LiveView</Button.button>
<Modal.modal
id="liveview-controlled-modal"
open={@modal_open}
on_close={JS.push("close-liveview-modal")}
>
<:title>Session details</:title>
<:description>LiveView owns the open state for this modal.</:description>
<p class="text-sm leading-6 text-muted-foreground">
Close the modal with the button, Escape key, or backdrop. The on_close command updates the server assign after every dismissal path.
</p>
<:footer>
<Button.button phx-click={Modal.hide("liveview-controlled-modal")}>Done</Button.button>
</:footer>
</Modal.modal>
</div>
Scrollable content
Long content scrolls inside the content region while the title, description, close control, and footer remain visible.
<div class="flex justify-center">
<Button.button kind={:outline} phx-click={Modal.show("scrolling-modal")}>
Review permissions
</Button.button>
<Modal.modal id="scrolling-modal" class="max-w-xl">
<:title>Workspace permissions</:title>
<:description>Review each permission before saving the role.</:description>
<ul class="-my-3 divide-y divide-border">
<li :for={permission <- [
"View projects",
"Create projects",
"Edit project settings",
"Invite members",
"Remove members",
"Manage billing",
"Create API keys",
"Run exports",
"View audit events",
"Manage integrations",
"Archive projects",
"Delete projects"
]} class="py-4">
<p class="text-sm font-medium">{permission}</p>
<p class="mt-1 text-sm leading-6 text-muted-foreground">
This role can use {String.downcase(permission)}.
</p>
</li>
</ul>
<:footer>
<Button.button kind={:ghost} phx-click={Modal.hide("scrolling-modal")}>Cancel</Button.button>
<Button.button phx-click={Modal.hide("scrolling-modal")}>Save role</Button.button>
</:footer>
</Modal.modal>
</div>
Accessibility
The native modal dialog traps focus, makes the surrounding page inert, renders in the top layer, and restores focus to the control that opened it. The required title labels the dialog; the optional description is connected with aria-describedby. Keyboard users can dismiss with Escape by default, and every example that disables a dismissal path keeps an explicit button available.
Add this module
mix dizzy.add modal
Component API
Modal.modal
Attributes
| Name | Type | Default | Values |
|---|---|---|---|
class |
:any
| nil | โ |
close_label |
:string
| "Close modal" | โ |
close_on_backdrop |
:boolean
| true | โ |
close_on_escape |
:boolean
| true | โ |
id |
:string
| Required | โ |
on_close |
:any
| %Phoenix.LiveView.JS{ops: []} | โ |
on_open |
:any
| %Phoenix.LiveView.JS{ops: []} | โ |
open |
:boolean
| nil | โ |
rest |
:global
| nil | โ |
show_close |
:boolean
| true | โ |
Slots
| Name | Required |
|---|---|
description | Optional |
footer | Optional |
inner_block | Required |
title | Required |
JavaScript hook
DizzyModal
Opens and closes the native dialog from Phoenix JS commands or a server-rendered open value, coordinates the scale and fade transition, runs lifecycle commands, and independently configures Escape and backdrop dismissal. The browser manages focus, top-layer stacking, and background inertness.
mix dizzy.add includes the hook and performs
any required asset setup automatically.