Dialog
A dialog is an overlay shown above other content in an application.
Built on the Zag.js dialog machine, opened and closed
through the native command/commandfor
attributes, so a trigger can live inside the Dialog or anywhere else on the page.
Usage
Internal trigger
<twig:ui:Dialog id="usage-dialog" class="rounded-lg border border-dashed border-border p-4">
<p class="mb-2 text-xs font-medium text-muted-foreground">Internal trigger</p>
<twig:ui:Dialog:Trigger>
<twig:ui:Button variant="outline" {{ ...dialog_trigger_attrs }} class="w-full">Open</twig:ui:Button>
</twig:ui:Dialog:Trigger>
<twig:ui:Dialog:Content>
<twig:ui:Dialog:Close />
<twig:ui:Dialog:Header>
<twig:ui:Dialog:Title>Edit profile</twig:ui:Dialog:Title>
<twig:ui:Dialog:Description>
Make changes to your profile here. Click save when you're done.
</twig:ui:Dialog:Description>
</twig:ui:Dialog:Header>
<twig:ui:Dialog:Footer>
<twig:ui:Button variant="outline" command="--close" commandfor="usage-dialog">Cancel</twig:ui:Button>
<twig:ui:Button command="--close" commandfor="usage-dialog">Save changes</twig:ui:Button>
</twig:ui:Dialog:Footer>
</twig:ui:Dialog:Content>
</twig:ui:Dialog>
Examples
Multiple triggers
Dialog:Trigger can be used more than once inside the same Dialog; every instance opens it.
Internal triggers
<twig:ui:Dialog id="multiple-triggers-dialog" class="rounded-lg border border-dashed border-border p-4">
<p class="mb-2 text-xs font-medium text-muted-foreground">Internal triggers</p>
<div class="flex gap-2">
<twig:ui:Dialog:Trigger>
<twig:ui:Button variant="outline" {{ ...dialog_trigger_attrs }}>Open from A</twig:ui:Button>
</twig:ui:Dialog:Trigger>
<twig:ui:Dialog:Trigger>
<twig:ui:Button variant="outline" {{ ...dialog_trigger_attrs }}>Open from B</twig:ui:Button>
</twig:ui:Dialog:Trigger>
</div>
<twig:ui:Dialog:Content class="max-w-md">
<twig:ui:Dialog:Close />
<twig:ui:Dialog:Header>
<twig:ui:Dialog:Title>Invite members</twig:ui:Dialog:Title>
<twig:ui:Dialog:Description>Both buttons above open this same dialog instance.</twig:ui:Dialog:Description>
</twig:ui:Dialog:Header>
<twig:ui:Dialog:Footer class="mt-auto">
<twig:ui:Button variant="outline" command="--close" commandfor="multiple-triggers-dialog">
Cancel
</twig:ui:Button>
<twig:ui:Button command="--close" commandfor="multiple-triggers-dialog">Send invite</twig:ui:Button>
</twig:ui:Dialog:Footer>
</twig:ui:Dialog:Content>
</twig:ui:Dialog>
Command triggers
Give the dialog an explicit id and point a command="--show-modal" button at it with
commandfor, from anywhere on the page. Dialog:Trigger uses the same attributes under the hood,
so it's a single API regardless of where the trigger lives.
No trigger inside, opened remotely
id as
commandfor.
<div class="flex flex-col items-center gap-8">
<twig:ui:Button command="--show-modal" commandfor="detached-example">
Open
</twig:ui:Button>
<twig:ui:Dialog id="detached-example" class="rounded-lg border border-dashed border-border p-4">
<p class="text-xs font-medium text-muted-foreground">No trigger inside, opened remotely</p>
<twig:ui:Dialog:Content class="max-w-md">
<twig:ui:Dialog:Close />
<twig:ui:Dialog:Header>
<twig:ui:Dialog:Title>Session expiring</twig:ui:Dialog:Title>
<twig:ui:Dialog:Description>
This dialog was opened from a button outside it, using its <code>id</code> as
<code>commandfor</code>.
</twig:ui:Dialog:Description>
</twig:ui:Dialog:Header>
<twig:ui:Dialog:Footer class="mt-auto">
<twig:ui:Button command="--close" commandfor="detached-example">Stay signed in</twig:ui:Button>
</twig:ui:Dialog:Footer>
</twig:ui:Dialog:Content>
</twig:ui:Dialog>
</div>
JavaScript triggers
Every Dialog element exposes show()/hide() methods, no command/commandfor trigger needed.
hide() still dispatches the cancelable hui:dialog:cancel event, same as escape, an outside
click, or a --close trigger.
<twig:ui:Button variant="outline" id="methods-show">Show with JavaScript</twig:ui:Button>
<twig:ui:Dialog id="methods-dialog">
<twig:ui:Dialog:Content class="max-w-md">
<twig:ui:Dialog:Header>
<twig:ui:Dialog:Title>Controlled with JavaScript</twig:ui:Dialog:Title>
<twig:ui:Dialog:Description>
This dialog has no Dialog:Trigger or Dialog:Close. It's opened and closed by
calling show() and hide() directly on the dialog element.
</twig:ui:Dialog:Description>
</twig:ui:Dialog:Header>
<twig:ui:Dialog:Footer>
<twig:ui:Button id="methods-hide">Hide with JavaScript</twig:ui:Button>
</twig:ui:Dialog:Footer>
</twig:ui:Dialog:Content>
</twig:ui:Dialog>
<script>
document.getElementById('methods-show').addEventListener('click', () => {
document.getElementById('methods-dialog').show();
});
document.getElementById('methods-hide').addEventListener('click', () => {
document.getElementById('methods-dialog').hide();
});
</script>
Nested dialogs
A Dialog can be opened from inside another Dialog:Content, no extra wiring needed: each
Dialog is an independent instance, and escape, outside clicks, and focus trapping all apply to
whichever one is on top. The parent dialog scales down and its backdrop darkens for every level of
nesting, so the stack stays legible.
<twig:ui:Dialog id="nested-root-dialog">
<twig:ui:Dialog:Trigger>
<twig:ui:Button variant="outline" {{ ...dialog_trigger_attrs }}>Open settings</twig:ui:Button>
</twig:ui:Dialog:Trigger>
<twig:ui:Dialog:Content class="h-64 max-w-md">
<twig:ui:Dialog:Close />
<twig:ui:Dialog:Header>
<twig:ui:Dialog:Title>Workspace settings</twig:ui:Dialog:Title>
<twig:ui:Dialog:Description>
A dialog can open other dialogs, to any depth.
</twig:ui:Dialog:Description>
</twig:ui:Dialog:Header>
<twig:ui:Dialog:Footer class="mt-auto">
<twig:ui:Dialog id="nested-changelog-dialog">
<twig:ui:Dialog:Trigger>
<twig:ui:Button variant="outline" {{ ...dialog_trigger_attrs }}>View changelog</twig:ui:Button>
</twig:ui:Dialog:Trigger>
<twig:ui:Dialog:Content class="h-64 max-w-md">
<twig:ui:Dialog:Close />
<twig:ui:Dialog:Header>
<twig:ui:Dialog:Title>Changelog</twig:ui:Dialog:Title>
<twig:ui:Dialog:Description>
You're all caught up, no new updates today. This dialog stops here, one level
deep.
</twig:ui:Dialog:Description>
</twig:ui:Dialog:Header>
</twig:ui:Dialog:Content>
</twig:ui:Dialog>
<twig:ui:Dialog id="nested-integrations-dialog">
<twig:ui:Dialog:Trigger>
<twig:ui:Button variant="outline" {{ ...dialog_trigger_attrs }}>Manage integrations</twig:ui:Button>
</twig:ui:Dialog:Trigger>
<twig:ui:Dialog:Content class="h-64 max-w-md">
<twig:ui:Dialog:Close />
<twig:ui:Dialog:Header>
<twig:ui:Dialog:Title>Integrations</twig:ui:Dialog:Title>
<twig:ui:Dialog:Description>
Connect external services to your workspace.
</twig:ui:Dialog:Description>
</twig:ui:Dialog:Header>
<twig:ui:Dialog:Footer class="mt-auto">
<twig:ui:Dialog id="nested-integration-detail-dialog">
<twig:ui:Dialog:Trigger>
<twig:ui:Button variant="outline" {{ ...dialog_trigger_attrs }}>Configure Slack</twig:ui:Button>
</twig:ui:Dialog:Trigger>
<twig:ui:Dialog:Content class="h-64 max-w-md">
<twig:ui:Dialog:Close />
<twig:ui:Dialog:Header>
<twig:ui:Dialog:Title>Slack integration</twig:ui:Dialog:Title>
<twig:ui:Dialog:Description>
A third dialog, nested two levels deep inside the first.
</twig:ui:Dialog:Description>
</twig:ui:Dialog:Header>
</twig:ui:Dialog:Content>
</twig:ui:Dialog>
</twig:ui:Dialog:Footer>
</twig:ui:Dialog:Content>
</twig:ui:Dialog>
</twig:ui:Dialog:Footer>
</twig:ui:Dialog:Content>
</twig:ui:Dialog>
No close button
Internal trigger
<twig:ui:Dialog id="no-close-dialog" class="rounded-lg border border-dashed border-border p-4">
<p class="mb-2 text-xs font-medium text-muted-foreground">Internal trigger</p>
<twig:ui:Dialog:Trigger>
<twig:ui:Button variant="outline" {{ ...dialog_trigger_attrs }} class="w-full">Open</twig:ui:Button>
</twig:ui:Dialog:Trigger>
<twig:ui:Dialog:Content>
<twig:ui:Dialog:Header>
<twig:ui:Dialog:Title>Are you absolutely sure?</twig:ui:Dialog:Title>
<twig:ui:Dialog:Description>
This action cannot be undone. This will permanently delete your account and remove
your data from our servers.
</twig:ui:Dialog:Description>
</twig:ui:Dialog:Header>
<twig:ui:Dialog:Footer>
<twig:ui:Button variant="outline" command="--close" commandfor="no-close-dialog">Cancel</twig:ui:Button>
<twig:ui:Button color="danger" command="--close" commandfor="no-close-dialog">Delete account</twig:ui:Button>
</twig:ui:Dialog:Footer>
</twig:ui:Dialog:Content>
</twig:ui:Dialog>
Custom close button
Override Dialog:Close's content block to render your own dismiss element instead of the
default ghost icon button. Spread dialog_close_attrs onto it to keep the same
--close/commandfor wiring.
Internal trigger
<twig:ui:Dialog id="custom-close-dialog" class="rounded-lg border border-dashed border-border p-4">
<p class="mb-2 text-xs font-medium text-muted-foreground">Internal trigger</p>
<twig:ui:Dialog:Trigger>
<twig:ui:Button variant="outline" {{ ...dialog_trigger_attrs }} class="w-full">Open</twig:ui:Button>
</twig:ui:Dialog:Trigger>
<twig:ui:Dialog:Content>
<twig:ui:Dialog:Close>
<twig:ui:Button
variant="outline"
size="icon-sm"
{{ ...dialog_close_attrs }}
class="absolute top-3 right-3 rounded-full"
aria-label="Close"
>
<twig:ux:icon name="lucide:x" class="size-4" />
</twig:ui:Button>
</twig:ui:Dialog:Close>
<twig:ui:Dialog:Header>
<twig:ui:Dialog:Title>Edit profile</twig:ui:Dialog:Title>
<twig:ui:Dialog:Description>
Make changes to your profile here. Click save when you're done.
</twig:ui:Dialog:Description>
</twig:ui:Dialog:Header>
<twig:ui:Dialog:Footer>
<twig:ui:Button variant="outline" command="--close" commandfor="custom-close-dialog">Cancel</twig:ui:Button>
<twig:ui:Button command="--close" commandfor="custom-close-dialog">Save changes</twig:ui:Button>
</twig:ui:Dialog:Footer>
</twig:ui:Dialog:Content>
</twig:ui:Dialog>
Sizes
There's no size prop: pick a max-w-* (or, for a full-screen dialog, h-*/w-* plus
max-w-none) and pass it as class on Dialog:Content. tailwind_merge takes care of dropping
the default max-w-sm.
class="max-w-md" on
Dialog:Content, nothing else.
class="max-w-2xl" instead.
tailwind_merge drops the default
max-w-sm automatically.
Dialog:Content
has its own p-4, which a size class alone
can't reach, so fixed inset-0 takes the
content out of that layout entirely and pins it to the viewport instead.
h-dvh w-dvw on top of that make sure it's
sized against the dynamic viewport, not the static one, so it doesn't get cut
off or leave a gap when mobile Safari's address bar shows or hides.
<div class="flex flex-wrap gap-2 rounded-lg border border-dashed border-border p-4">
<twig:ui:Dialog id="size-medium-dialog">
<twig:ui:Dialog:Trigger>
<twig:ui:Button variant="outline" {{ ...dialog_trigger_attrs }}>Medium</twig:ui:Button>
</twig:ui:Dialog:Trigger>
<twig:ui:Dialog:Content class="max-w-md">
<twig:ui:Dialog:Close />
<twig:ui:Dialog:Header>
<twig:ui:Dialog:Title>Medium dialog</twig:ui:Dialog:Title>
<twig:ui:Dialog:Description>
Just <code class="text-foreground">class="max-w-md"</code> on
<code class="text-foreground">Dialog:Content</code>, nothing else.
</twig:ui:Dialog:Description>
</twig:ui:Dialog:Header>
</twig:ui:Dialog:Content>
</twig:ui:Dialog>
<twig:ui:Dialog id="size-large-dialog">
<twig:ui:Dialog:Trigger>
<twig:ui:Button variant="outline" {{ ...dialog_trigger_attrs }}>Large</twig:ui:Button>
</twig:ui:Dialog:Trigger>
<twig:ui:Dialog:Content class="max-w-2xl">
<twig:ui:Dialog:Close />
<twig:ui:Dialog:Header>
<twig:ui:Dialog:Title>Large dialog</twig:ui:Dialog:Title>
<twig:ui:Dialog:Description>
<code class="text-foreground">class="max-w-2xl"</code> instead.
<code class="text-foreground">tailwind_merge</code> drops the default
<code class="text-foreground">max-w-sm</code> automatically.
</twig:ui:Dialog:Description>
</twig:ui:Dialog:Header>
</twig:ui:Dialog:Content>
</twig:ui:Dialog>
<twig:ui:Dialog id="size-fullscreen-dialog">
<twig:ui:Dialog:Trigger>
<twig:ui:Button variant="outline" {{ ...dialog_trigger_attrs }}>Full screen</twig:ui:Button>
</twig:ui:Dialog:Trigger>
<twig:ui:Dialog:Content class="fixed inset-0 h-dvh w-dvw max-w-none rounded-none">
<twig:ui:Dialog:Close />
<twig:ui:Dialog:Header>
<twig:ui:Dialog:Title>Full screen dialog</twig:ui:Dialog:Title>
<twig:ui:Dialog:Description>
The positioner that centers <code class="text-foreground">Dialog:Content</code>
has its own <code class="text-foreground">p-4</code>, which a size class alone
can't reach, so <code class="text-foreground">fixed inset-0</code> takes the
content out of that layout entirely and pins it to the viewport instead.
<code class="text-foreground">h-dvh w-dvw</code> on top of that make sure it's
sized against the dynamic viewport, not the static one, so it doesn't get cut
off or leave a gap when mobile Safari's address bar shows or hides.
</twig:ui:Dialog:Description>
</twig:ui:Dialog:Header>
</twig:ui:Dialog:Content>
</twig:ui:Dialog>
</div>
Close confirmation
Escape, an outside click, and the --close command all dispatch a cancelable
hui:dialog:cancel event on the dialog element first. Call event.preventDefault() on it to
keep the dialog open, for example to show a nested confirm dialog instead.
<div
data-controller="dialog-confirm-close"
data-dialog-confirm-close-confirm-dialog-value="discard-dialog"
data-dialog-confirm-close-close-trigger-value="edit-save"
data-dialog-confirm-close-discard-value="discard-confirm"
>
<twig:ui:Dialog id="edit-dialog">
<twig:ui:Dialog:Trigger>
<twig:ui:Button variant="outline" {{ ...dialog_trigger_attrs }}>Edit profile</twig:ui:Button>
</twig:ui:Dialog:Trigger>
<twig:ui:Dialog:Content class="max-w-md">
<twig:ui:Dialog:Close />
<twig:ui:Dialog:Header>
<twig:ui:Dialog:Title>Edit profile</twig:ui:Dialog:Title>
<twig:ui:Dialog:Description>Closing now discards your changes.</twig:ui:Dialog:Description>
</twig:ui:Dialog:Header>
<twig:ui:Dialog:Footer class="mt-auto">
<twig:ui:Button variant="outline" command="--show-modal" commandfor="discard-dialog">
Cancel
</twig:ui:Button>
<twig:ui:Button id="edit-save" command="--close" commandfor="edit-dialog">
Save changes
</twig:ui:Button>
</twig:ui:Dialog:Footer>
</twig:ui:Dialog:Content>
</twig:ui:Dialog>
</div>
<twig:ui:Dialog id="discard-dialog">
<twig:ui:Dialog:Content class="max-w-md">
<twig:ui:Dialog:Close />
<twig:ui:Dialog:Header>
<twig:ui:Dialog:Title>Discard changes?</twig:ui:Dialog:Title>
<twig:ui:Dialog:Description>Your edits haven't been saved yet.</twig:ui:Dialog:Description>
</twig:ui:Dialog:Header>
<twig:ui:Dialog:Footer class="mt-auto">
<twig:ui:Button variant="outline" command="--close" commandfor="discard-dialog">
Go back
</twig:ui:Button>
<twig:ui:Button color="danger" id="discard-confirm" command="--close" commandfor="discard-dialog">
Discard
</twig:ui:Button>
</twig:ui:Dialog:Footer>
</twig:ui:Dialog:Content>
</twig:ui:Dialog>
Events
Open your browser console to see hui:dialog:open, hui:dialog:close, and hui:dialog:cancel
logged as you open, close, or dismiss the dialog.
<twig:ui:Dialog id="events-dialog">
<twig:ui:Dialog:Trigger>
<twig:ui:Button variant="outline" {{ ...dialog_trigger_attrs }}>Open dialog</twig:ui:Button>
</twig:ui:Dialog:Trigger>
<twig:ui:Dialog:Content class="max-w-md">
<twig:ui:Dialog:Close />
<twig:ui:Dialog:Header>
<twig:ui:Dialog:Title>Check the console</twig:ui:Dialog:Title>
<twig:ui:Dialog:Description>
Opening, closing, or dismissing this dialog logs the hui:dialog:open,
hui:dialog:close, and hui:dialog:cancel events.
</twig:ui:Dialog:Description>
</twig:ui:Dialog:Header>
</twig:ui:Dialog:Content>
</twig:ui:Dialog>
<script>
const eventsDialog = document.getElementById('events-dialog');
for (const name of ['hui:dialog:open', 'hui:dialog:close', 'hui:dialog:cancel']) {
eventsDialog.addEventListener(name, (event) => console.log(name, event));
}
</script>
Scrolling inside
Cap Dialog:Content with a max-h-* and split it into a fixed header/footer around an
overflow-y-auto region, so the dialog stays fully on screen and only its content scrolls.
By using this service, you agree to use it only for its intended purpose and to respect the rights of other users.
We may update these terms from time to time. Continued use of the service after a change means you accept the revised terms.
Your account is yours to manage. Keep your credentials private and let us know immediately if you suspect unauthorized access.
We collect the minimum data required to operate the service and never sell it to third parties.
The service is provided as is, without warranties of any kind, express or implied.
We may suspend or terminate access if these terms are violated, with notice whenever reasonably possible.
Questions about these terms can be sent to our support team at any time.
<twig:ui:Dialog id="scroll-inside-dialog">
<twig:ui:Dialog:Trigger>
<twig:ui:Button variant="outline" {{ ...dialog_trigger_attrs }}>View terms</twig:ui:Button>
</twig:ui:Dialog:Trigger>
<twig:ui:Dialog:Content class="grid-rows-[auto_1fr_auto] max-h-[26rem] max-w-md gap-0 overflow-hidden p-0">
<twig:ui:Dialog:Close />
<twig:ui:Dialog:Header class="border-b border-foreground/10 p-4">
<twig:ui:Dialog:Title>Terms of service</twig:ui:Dialog:Title>
</twig:ui:Dialog:Header>
<div class="overflow-y-auto p-4 text-muted-foreground">
<p class="mb-3">
By using this service, you agree to use it only for its intended purpose and to
respect the rights of other users.
</p>
<p class="mb-3">
We may update these terms from time to time. Continued use of the service after a
change means you accept the revised terms.
</p>
<p class="mb-3">
Your account is yours to manage. Keep your credentials private and let us know
immediately if you suspect unauthorized access.
</p>
<p class="mb-3">
We collect the minimum data required to operate the service and never sell it to
third parties.
</p>
<p class="mb-3">
The service is provided as is, without warranties of any kind, express or implied.
</p>
<p class="mb-3">
We may suspend or terminate access if these terms are violated, with notice
whenever reasonably possible.
</p>
<p>
Questions about these terms can be sent to our support team at any time.
</p>
</div>
<twig:ui:Dialog:Footer class="border-t border-foreground/10 p-4">
<twig:ui:Button variant="outline" command="--close" commandfor="scroll-inside-dialog">
Decline
</twig:ui:Button>
<twig:ui:Button command="--close" commandfor="scroll-inside-dialog">Accept</twig:ui:Button>
</twig:ui:Dialog:Footer>
</twig:ui:Dialog:Content>
</twig:ui:Dialog>
Scrolling outside
For content that should keep growing instead, make the positioner itself scrollable so the page
scrolls around the dialog and it can extend past the viewport instead of being clipped. The
positioner is portaled to <body> on connect, so it can't be targeted with an ancestor selector
from the Dialog root; target it by data-owner (the dialog's id) instead.
v1.20.0 Added a --toggle command so a single trigger can open and close the same dialog.
v1.19.0 Improved focus return to the trigger that opened the dialog, even across nested levels.
v1.18.0 Added a data attribute hook for styling the dialog while its close transition plays out.
v1.17.0 Fixed a scroll-lock issue where the page could jump when a dialog opened on a short viewport.
v1.16.0 Backdrop darkening now accounts for every level of nesting instead of just the first.
v1.15.0 Added an escape-key override so a single dialog can opt out of dismissing on escape.
v1.14.0 Content is announced to screen readers as soon as it becomes visible, not on open start.
v1.13.0 Outside clicks are now ignored while a nested dialog is open, matching native modal behavior.
v1.12.0 Added a way to disable the transition entirely for users who prefer reduced motion.
v1.11.0 The backdrop now forwards clicks correctly when a parent element has its own click handler.
v1.10.0 Added support for opening a dialog directly from a keyboard shortcut, without a visible trigger.
v1.9.0 Fixed the initial focus target when the dialog contains no focusable element.
v1.8.0 Tightened the type definitions around the trigger attributes for stricter editors.
v1.7.0 Nested dialogs now restore the parent's scale and position in a single frame on close.
v1.6.0 Added a changelog page, because apparently we needed one long enough to prove a point.
v1.5.0 Outside clicks are now ignored while a nested dialog is open, matching native modal behavior.
v1.4.0 Added nested dialog support with automatic stacking.
v1.3.0 Introduced the close confirmation event, cancelable from userland.
v1.2.0 Detached triggers can now open a dialog from anywhere on the page.
v1.1.0 Multiple triggers can share a single dialog instance.
v1.0.0 Initial release, built on the Zag.js dialog machine.
v0.9.0 Migrated from a custom focus trap to the native command/commandfor attributes.
v0.8.0 Added support for nested backdrop darkening.
v0.7.0 Fixed a memory leak when a dialog was opened and closed in rapid succession.
v0.6.0 Added the first pass at reduced-motion support for the backdrop fade.
v0.5.0 Positioner now accounts for the scrollbar width to avoid a layout shift on open.
v0.4.0 Added support for a custom id so a trigger anywhere on the page can target the dialog.
v0.3.0 Escape now closes only the topmost dialog instead of the entire stack.
v0.2.0 Added the backdrop and basic open and close animations.
v0.1.0 First internal preview of the dialog component.
<style>
[data-owner="scroll-outside-dialog"][data-part="positioner"] {
overflow-y: auto;
align-items: safe center;
}
</style>
<twig:ui:Dialog id="scroll-outside-dialog">
<twig:ui:Dialog:Trigger>
<twig:ui:Button variant="outline" {{ ...dialog_trigger_attrs }}>View changelog</twig:ui:Button>
</twig:ui:Dialog:Trigger>
<twig:ui:Dialog:Content class="my-4 max-w-md">
<twig:ui:Dialog:Title>Changelog</twig:ui:Dialog:Title>
<div class="space-y-3 text-muted-foreground">
<p><span class="font-medium text-popover-foreground">v1.20.0</span> Added a <code>--toggle</code> command so a single trigger can open and close the same dialog.</p>
<p><span class="font-medium text-popover-foreground">v1.19.0</span> Improved focus return to the trigger that opened the dialog, even across nested levels.</p>
<p><span class="font-medium text-popover-foreground">v1.18.0</span> Added a data attribute hook for styling the dialog while its close transition plays out.</p>
<p><span class="font-medium text-popover-foreground">v1.17.0</span> Fixed a scroll-lock issue where the page could jump when a dialog opened on a short viewport.</p>
<p><span class="font-medium text-popover-foreground">v1.16.0</span> Backdrop darkening now accounts for every level of nesting instead of just the first.</p>
<p><span class="font-medium text-popover-foreground">v1.15.0</span> Added an escape-key override so a single dialog can opt out of dismissing on escape.</p>
<p><span class="font-medium text-popover-foreground">v1.14.0</span> Content is announced to screen readers as soon as it becomes visible, not on open start.</p>
<p><span class="font-medium text-popover-foreground">v1.13.0</span> Outside clicks are now ignored while a nested dialog is open, matching native modal behavior.</p>
<p><span class="font-medium text-popover-foreground">v1.12.0</span> Added a way to disable the transition entirely for users who prefer reduced motion.</p>
<p><span class="font-medium text-popover-foreground">v1.11.0</span> The backdrop now forwards clicks correctly when a parent element has its own click handler.</p>
<p><span class="font-medium text-popover-foreground">v1.10.0</span> Added support for opening a dialog directly from a keyboard shortcut, without a visible trigger.</p>
<p><span class="font-medium text-popover-foreground">v1.9.0</span> Fixed the initial focus target when the dialog contains no focusable element.</p>
<p><span class="font-medium text-popover-foreground">v1.8.0</span> Tightened the type definitions around the trigger attributes for stricter editors.</p>
<p><span class="font-medium text-popover-foreground">v1.7.0</span> Nested dialogs now restore the parent's scale and position in a single frame on close.</p>
<p><span class="font-medium text-popover-foreground">v1.6.0</span> Added a changelog page, because apparently we needed one long enough to prove a point.</p>
<p><span class="font-medium text-popover-foreground">v1.5.0</span> Outside clicks are now ignored while a nested dialog is open, matching native modal behavior.</p>
<p><span class="font-medium text-popover-foreground">v1.4.0</span> Added nested dialog support with automatic stacking.</p>
<p><span class="font-medium text-popover-foreground">v1.3.0</span> Introduced the close confirmation event, cancelable from userland.</p>
<p><span class="font-medium text-popover-foreground">v1.2.0</span> Detached triggers can now open a dialog from anywhere on the page.</p>
<p><span class="font-medium text-popover-foreground">v1.1.0</span> Multiple triggers can share a single dialog instance.</p>
<p><span class="font-medium text-popover-foreground">v1.0.0</span> Initial release, built on the Zag.js dialog machine.</p>
<p><span class="font-medium text-popover-foreground">v0.9.0</span> Migrated from a custom focus trap to the native command/commandfor attributes.</p>
<p><span class="font-medium text-popover-foreground">v0.8.0</span> Added support for nested backdrop darkening.</p>
<p><span class="font-medium text-popover-foreground">v0.7.0</span> Fixed a memory leak when a dialog was opened and closed in rapid succession.</p>
<p><span class="font-medium text-popover-foreground">v0.6.0</span> Added the first pass at reduced-motion support for the backdrop fade.</p>
<p><span class="font-medium text-popover-foreground">v0.5.0</span> Positioner now accounts for the scrollbar width to avoid a layout shift on open.</p>
<p><span class="font-medium text-popover-foreground">v0.4.0</span> Added support for a custom id so a trigger anywhere on the page can target the dialog.</p>
<p><span class="font-medium text-popover-foreground">v0.3.0</span> Escape now closes only the topmost dialog instead of the entire stack.</p>
<p><span class="font-medium text-popover-foreground">v0.2.0</span> Added the backdrop and basic open and close animations.</p>
<p><span class="font-medium text-popover-foreground">v0.1.0</span> First internal preview of the dialog component.</p>
</div>
<twig:ui:Dialog:Footer class="mt-auto">
<twig:ui:Dialog:Close>
<twig:ui:Button {{ ...dialog_close_attrs }}>Close</twig:ui:Button>
</twig:ui:Dialog:Close>
</twig:ui:Dialog:Footer>
</twig:ui:Dialog:Content>
</twig:ui:Dialog>
Non-modal
Set modal="false" to stop trapping focus inside the dialog. preventScroll and
closeOnInteractOutside aren't tied to it, so set them too if you want the rest of the page to
stay fully interactive while the dialog is open.
modal="false", Tab isn't trapped
here: keep pressing it and focus leaves the dialog for the rest of the page's
tab order, instead of cycling back to the close button. Since
Dialog:Content is portaled to the end of
<body>, that's usually not "Page
button (after)" right below, it's whatever comes first in the page. Also note
preventScroll and
closeOnInteractOutside were set to
false too, so the rest of the page stays
scrollable while it's open — modal alone
doesn't touch either of them.
<div class="flex flex-col items-start gap-3 rounded-lg border border-dashed border-border p-4">
<twig:ui:Button variant="outline">Page button (before)</twig:ui:Button>
<twig:ui:Dialog id="non-modal-dialog" :modal="false" :preventScroll="false" :closeOnInteractOutside="false">
<twig:ui:Dialog:Trigger>
<twig:ui:Button variant="outline" {{ ...dialog_trigger_attrs }}>Open non-modal dialog</twig:ui:Button>
</twig:ui:Dialog:Trigger>
<twig:ui:Dialog:Content>
<twig:ui:Dialog:Close />
<twig:ui:Dialog:Header>
<twig:ui:Dialog:Title>Non-modal dialog</twig:ui:Dialog:Title>
<twig:ui:Dialog:Description>
With <code class="text-foreground">modal="false"</code>, Tab isn't trapped
here: keep pressing it and focus leaves the dialog for the rest of the page's
tab order, instead of cycling back to the close button. Since
<code class="text-foreground">Dialog:Content</code> is portaled to the end of
<code class="text-foreground"><body></code>, that's usually not "Page
button (after)" right below, it's whatever comes first in the page. Also note
<code class="text-foreground">preventScroll</code> and
<code class="text-foreground">closeOnInteractOutside</code> were set to
<code class="text-foreground">false</code> too, so the rest of the page stays
scrollable while it's open — <code class="text-foreground">modal</code> alone
doesn't touch either of them.
</twig:ui:Dialog:Description>
</twig:ui:Dialog:Header>
</twig:ui:Dialog:Content>
</twig:ui:Dialog>
<twig:ui:Button variant="outline">Page button (after)</twig:ui:Button>
</div>
Right-to-left
Set dir="rtl" to flip the close button, footer button order, and text alignment. Backdrop and
positioner are portaled to the end of <body> on connect, so they can't inherit dir from a
locale-scoped subtree the way a regular in-place element would: pass it explicitly whenever the
page's <html dir> doesn't already match.
<twig:ui:Dialog id="rtl-example" dir="rtl">
<twig:ui:Dialog:Trigger>
<twig:ui:Button variant="outline" {{ ...dialog_trigger_attrs }}>حذف الحساب</twig:ui:Button>
</twig:ui:Dialog:Trigger>
<twig:ui:Dialog:Content class="max-w-md">
<twig:ui:Dialog:Close />
<twig:ui:Dialog:Header>
<twig:ui:Dialog:Title>تأكيد الحذف</twig:ui:Dialog:Title>
<twig:ui:Dialog:Description>لا يمكن التراجع عن هذا الإجراء بعد تأكيده.</twig:ui:Dialog:Description>
</twig:ui:Dialog:Header>
<twig:ui:Dialog:Footer class="mt-auto">
<twig:ui:Button variant="outline" command="--close" commandfor="rtl-example">إلغاء</twig:ui:Button>
<twig:ui:Button color="danger" command="--close" commandfor="rtl-example">حذف</twig:ui:Button>
</twig:ui:Dialog:Footer>
</twig:ui:Dialog:Content>
</twig:ui:Dialog>
Focus management
By default, opening a Dialog focuses Dialog:Content itself rather than the first focusable
element inside it, so a leading Dialog:Close button (or a destructive footer action) never
grabs focus by accident. To focus a specific element instead, add autofocus (or data-autofocus)
to it, no matter where it sits in the markup:
Internal trigger
autofocus, so it's the one
that gets focus when the dialog opens, regardless of tab order.
<twig:ui:Dialog id="initial-focus-dialog" class="rounded-lg border border-dashed border-border p-4">
<p class="mb-2 text-xs font-medium text-muted-foreground">Internal trigger</p>
<twig:ui:Dialog:Trigger>
<twig:ui:Button variant="outline" {{ ...dialog_trigger_attrs }} class="w-full">Open</twig:ui:Button>
</twig:ui:Dialog:Trigger>
<twig:ui:Dialog:Content>
<twig:ui:Dialog:Header>
<twig:ui:Dialog:Title>Choose a plan</twig:ui:Dialog:Title>
<twig:ui:Dialog:Description>
Only "Pro" has <code class="text-foreground">autofocus</code>, so it's the one
that gets focus when the dialog opens, regardless of tab order.
</twig:ui:Dialog:Description>
</twig:ui:Dialog:Header>
<twig:ui:Dialog:Footer class="flex-wrap justify-start">
<twig:ui:Button variant="outline">Starter</twig:ui:Button>
<twig:ui:Button autofocus>Pro (autofocus)</twig:ui:Button>
<twig:ui:Button variant="outline">Enterprise</twig:ui:Button>
</twig:ui:Dialog:Footer>
</twig:ui:Dialog:Content>
</twig:ui:Dialog>
Closing the dialog returns focus to whatever had it before the dialog opened, typically the
trigger. To send focus somewhere else instead, set data-dialog-final-focus to the dialog's
id on the element that should receive it, anywhere on the page (not necessarily inside
Dialog:Content, which is hidden once closed). Scoping it by id rather than just marking an
element means several dialogs on the same page, each with their own final-focus target, never
collide with each other:
data-dialog-final-focus="final-focus-dialog".
<div class="flex flex-wrap items-center gap-2 rounded-lg border border-dashed border-border p-4">
<twig:ui:Dialog id="final-focus-dialog">
<twig:ui:Dialog:Trigger>
<twig:ui:Button variant="outline" {{ ...dialog_trigger_attrs }}>Rename item</twig:ui:Button>
</twig:ui:Dialog:Trigger>
<twig:ui:Dialog:Content>
<twig:ui:Dialog:Header>
<twig:ui:Dialog:Title>Rename item</twig:ui:Dialog:Title>
<twig:ui:Dialog:Description>
Closing this sends focus to "Next item" instead of back to the "Rename item"
trigger, because it carries
<code class="text-foreground">data-dialog-final-focus="final-focus-dialog"</code>.
</twig:ui:Dialog:Description>
</twig:ui:Dialog:Header>
<twig:ui:Dialog:Footer>
<twig:ui:Button variant="outline" command="--close" commandfor="final-focus-dialog">Cancel</twig:ui:Button>
<twig:ui:Button command="--close" commandfor="final-focus-dialog">Save</twig:ui:Button>
</twig:ui:Dialog:Footer>
</twig:ui:Dialog:Content>
</twig:ui:Dialog>
<twig:ui:Button variant="ghost" data-dialog-final-focus="final-focus-dialog">Next item</twig:ui:Button>
</div>
API Reference
<twig:ui:Dialog>
| Prop | Type | Description |
|---|---|---|
open |
boolean |
Whether the dialog is open on initial render. Use show()/hide() to control it dynamically afterwards. Defaults to false |
id |
string |
The dialog's DOM id, used as the commandfor target. Defaults to an auto-generated value |
role |
'dialog' | 'alertdialog' |
The content part's ARIA role. Use alertdialog for interruptive confirmations. Defaults to 'dialog' |
preventScroll |
boolean |
Whether to lock body scroll while the dialog is open. Defaults to true |
closeOnInteractOutside |
boolean |
Whether a pointer interaction outside Dialog:Content closes the dialog. Defaults to true |
closeOnEscape |
boolean |
Whether pressing escape closes the dialog. Defaults to true |
modal |
boolean |
Whether the dialog traps focus inside itself. Defaults to true. Doesn't affect preventScroll or closeOnInteractOutside, which stay controlled by their own props |
ariaLabel |
string |
Accessible name for Dialog:Content when it doesn't render a Dialog:Title. Defaults to null |
dir |
'ltr' | 'rtl' |
Text direction applied to every dialog part. Defaults to the page's <html dir>, useful when the dialog is portaled out of a locale-scoped subtree that isn't reflected on <html> |
as |
string |
The element to render the root wrapper as. Defaults to div |
For an interruptive confirmation that can only be dismissed through an explicit action, use
AlertDialog, a preset over these props.
| Block | Description |
|---|---|
content |
The dialog structure, typically one or more Dialog:Trigger and a Dialog:Content |
| Method | Description |
|---|---|
show() |
Shows the dialog. |
hide() |
Hides the dialog. |
| Event | Description |
|---|---|
hui:dialog:cancel |
Cancelable. Dispatched before escape, an outside click, --close, or hide() dismiss the dialog. Call event.preventDefault() to keep it open |
hui:dialog:open |
Dispatched after the dialog opens, however it was triggered |
hui:dialog:close |
Dispatched after the dialog closes, however it was triggered |
<twig:ui:Dialog:Trigger>
Exposes a dialog_trigger_attrs variable (command="--show-modal", commandfor="<dialog id>") to
spread onto the element that opens the dialog. Can be used more than once inside the same Dialog.
Only useful when the trigger is nested inside the Dialog; a remote trigger sets
command/commandfor directly (see the example above).
| Block | Description |
|---|---|
content |
The trigger element (e.g. a Button with {{ ...dialog_trigger_attrs }}) |
<twig:ui:Dialog:Content>
Renders the backdrop, positioner and content parts of the dialog.
| Prop | Type | Description |
|---|---|---|
as |
string |
The element to render the content part as. Defaults to div |
| Block | Description |
|---|---|
content |
The dialog content |
<twig:ui:Dialog:Header>
Lays out a title and description stacked at the top of the dialog.
| Prop | Type | Description |
|---|---|---|
as |
string |
The element to render. Defaults to div |
| Block | Description |
|---|---|
content |
Typically a Dialog:Title and a Dialog:Description |
<twig:ui:Dialog:Title>
The dialog's accessible name. Its id is wired to the content's aria-labelledby automatically.
| Prop | Type | Description |
|---|---|---|
as |
string |
The element to render. Defaults to div |
| Block | Description |
|---|---|
content |
The title text |
<twig:ui:Dialog:Description>
The dialog's accessible description. Its id is wired to the content's aria-describedby
automatically.
| Prop | Type | Description |
|---|---|---|
as |
string |
The element to render. Defaults to div |
| Block | Description |
|---|---|
content |
The description text |
<twig:ui:Dialog:Footer>
Right-aligns actions at the bottom of the dialog.
| Prop | Type | Description |
|---|---|---|
as |
string |
The element to render. Defaults to div |
| Block | Description |
|---|---|
content |
Typically one or more Button elements |
<twig:ui:Dialog:Close>
Exposes a dialog_close_attrs variable (command="--close", commandfor="<dialog id>") to spread
onto a custom dismiss element, same mechanism as Dialog:Trigger. Defaults to a ghost icon button
with an x icon, positioned in the top-right corner of Dialog:Content.
| Block | Description |
|---|---|
content |
The dismiss element. Defaults to a Button with {{ ...dialog_close_attrs }} |