Dialog
A modal window that moves focus into a task and returns it when dismissed.
@data-slot/dialogSource ↗bun add @data-slot/dialognpm install @data-slot/dialogpnpm add @data-slot/dialogAnatomy
Both dialog-content and dialog-overlay are required. Add dialog-title and dialog-description and they become the dialog’s accessible name and description.
<div data-slot="dialog">
<button data-slot="dialog-trigger">Open</button>
<div data-slot="dialog-overlay" hidden></div>
<div data-slot="dialog-content" role="dialog" hidden>
<h2 data-slot="dialog-title">Title</h2>
<p data-slot="dialog-description">Description</p>
<button data-slot="dialog-close">Close</button>
</div>
</div>
Examples
Open a modal
Confirm Action
This dialog traps focus and can be closed with Escape or clicking outside.
Confirm Action
This dialog traps focus and can be closed with Escape or clicking outside.
Show code
<div data-slot="dialog">
<button data-slot="dialog-trigger" class="dialog-trigger-btn">Open Dialog</button>
<div data-slot="dialog-overlay" class="dialog-overlay" hidden></div>
<div data-slot="dialog-content" class="dialog-panel" hidden>
<h2 data-slot="dialog-title" class="dialog-title">Confirm Action</h2>
<p data-slot="dialog-description" class="dialog-description">
This dialog traps focus and can be closed with Escape or clicking outside.
</p>
<button data-slot="dialog-close" class="dialog-close-btn">Close</button>
</div>
</div>
<style>
.dialog-trigger-btn {
padding: 0.5rem 1rem;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
cursor: pointer;
}
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 100;
opacity: 0;
transition: opacity 0.2s ease;
}
.dialog-overlay[data-open] { opacity: 1; }
.dialog-overlay[data-starting-style],
.dialog-overlay[data-ending-style] { opacity: 0; }
.dialog-overlay[hidden] { display: none; }
.dialog-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.95);
background: var(--surface);
padding: 2rem;
max-width: 28rem;
width: 91.666667%;
border: 1px solid var(--border);
z-index: 101;
opacity: 0;
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.dialog-panel[data-open] {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
.dialog-panel[data-starting-style],
.dialog-panel[data-ending-style] {
opacity: 0;
transform: translate(-50%, -50%) scale(0.95);
}
.dialog-panel[hidden] { display: none; }
.dialog-title { font-weight: 700; margin-bottom: 0.5rem;
font-size: 0.875rem;
line-height: 1.25rem;
margin-top: 0;
}
.dialog-description { color: var(--muted); margin-bottom: 1.5rem;
margin-top: 0;
}
.dialog-close-btn {
padding: 0.375rem 0.75rem;
background: none;
border: 1px solid var(--border);
cursor: pointer;
font-size: inherit;
}
</style><div data-slot="dialog" class="group/dialog">
<button
data-slot="dialog-trigger"
class="px-4 py-2 bg-[var(--surface)] text-text border border-[var(--border)] cursor-pointer
hover:opacity-90 transition-opacity"
>
Open Dialog
</button>
<div
data-slot="dialog-overlay"
class="fixed inset-0 bg-black/50 z-50 opacity-0 transition-opacity duration-200
data-open:opacity-100
data-starting-style:opacity-0
data-ending-style:opacity-0"
hidden
></div>
<div
data-slot="dialog-content"
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
bg-[var(--surface)] p-8 max-w-md w-11/12 border border-[var(--border)] z-50
opacity-0 scale-95 transition-all duration-200
data-open:opacity-100
data-open:scale-100
data-starting-style:opacity-0
data-starting-style:scale-95
data-ending-style:opacity-0
data-ending-style:scale-95"
hidden
>
<h2 data-slot="dialog-title" class="text-sm font-bold mb-2 mt-0">Confirm Action</h2>
<p data-slot="dialog-description" class="text-[var(--muted)] mb-6">
This dialog traps focus and can be closed with Escape or clicking outside.
</p>
<button
data-slot="dialog-close"
class="px-3 py-1.5 bg-transparent border border-[var(--border)] cursor-pointer
hover:bg-code-bg transition-colors"
>
Close
</button>
</div>
</div>import { create } from "@data-slot/dialog";
// Initialize after the markup is in the document.
const controllers = create();
// Clean up before removing the component.
// controllers.forEach((controller) => controller.destroy());Require an explicit close
data-close-on-click-outside="false" keeps the dialog open when you click outside. The close button and Escape still work.
Confirm Action
This dialog traps focus and can be closed with Escape or clicking outside.
Confirm Action
This dialog traps focus and can be closed with Escape or clicking outside.
Show code
<div data-slot="dialog" data-close-on-click-outside="false">
<button data-slot="dialog-trigger" class="dialog-trigger-btn">Open Dialog</button>
<div data-slot="dialog-overlay" class="dialog-overlay" hidden></div>
<div data-slot="dialog-content" class="dialog-panel" hidden>
<h2 data-slot="dialog-title" class="dialog-title">Confirm Action</h2>
<p data-slot="dialog-description" class="dialog-description">
This dialog traps focus and can be closed with Escape or clicking outside.
</p>
<button data-slot="dialog-close" class="dialog-close-btn">Close</button>
</div>
</div>
<style>
.dialog-trigger-btn {
padding: 0.5rem 1rem;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
cursor: pointer;
}
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 100;
opacity: 0;
transition: opacity 0.2s ease;
}
.dialog-overlay[data-open] { opacity: 1; }
.dialog-overlay[data-starting-style],
.dialog-overlay[data-ending-style] { opacity: 0; }
.dialog-overlay[hidden] { display: none; }
.dialog-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.95);
background: var(--surface);
padding: 2rem;
max-width: 28rem;
width: 91.666667%;
border: 1px solid var(--border);
z-index: 101;
opacity: 0;
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.dialog-panel[data-open] {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
.dialog-panel[data-starting-style],
.dialog-panel[data-ending-style] {
opacity: 0;
transform: translate(-50%, -50%) scale(0.95);
}
.dialog-panel[hidden] { display: none; }
.dialog-title { font-weight: 700; margin-bottom: 0.5rem;
font-size: 0.875rem;
line-height: 1.25rem;
margin-top: 0;
}
.dialog-description { color: var(--muted); margin-bottom: 1.5rem;
margin-top: 0;
}
.dialog-close-btn {
padding: 0.375rem 0.75rem;
background: none;
border: 1px solid var(--border);
cursor: pointer;
font-size: inherit;
}
</style><div data-slot="dialog" data-close-on-click-outside="false" class="group/dialog">
<button
data-slot="dialog-trigger"
class="px-4 py-2 bg-[var(--surface)] text-text border border-[var(--border)] cursor-pointer
hover:opacity-90 transition-opacity"
>
Open Dialog
</button>
<div
data-slot="dialog-overlay"
class="fixed inset-0 bg-black/50 z-50 opacity-0 transition-opacity duration-200
data-open:opacity-100
data-starting-style:opacity-0
data-ending-style:opacity-0"
hidden
></div>
<div
data-slot="dialog-content"
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
bg-[var(--surface)] p-8 max-w-md w-11/12 border border-[var(--border)] z-50
opacity-0 scale-95 transition-all duration-200
data-open:opacity-100
data-open:scale-100
data-starting-style:opacity-0
data-starting-style:scale-95
data-ending-style:opacity-0
data-ending-style:scale-95"
hidden
>
<h2 data-slot="dialog-title" class="text-sm font-bold mb-2 mt-0">Confirm Action</h2>
<p data-slot="dialog-description" class="text-[var(--muted)] mb-6">
This dialog traps focus and can be closed with Escape or clicking outside.
</p>
<button
data-slot="dialog-close"
class="px-3 py-1.5 bg-transparent border border-[var(--border)] cursor-pointer
hover:bg-code-bg transition-colors"
>
Close
</button>
</div>
</div>import { create } from "@data-slot/dialog";
// Initialize after the markup is in the document.
const controllers = create();
// Clean up before removing the component.
// controllers.forEach((controller) => controller.destroy());API reference
Initialization
create(scope?)
Auto-discover and bind all dialog instances in a scope (defaults to document).
import { create } from "@data-slot/dialog";
const controllers = create(); // Returns DialogController[]
createDialog(root, options?)
Create a controller for a specific element.
import { createDialog } from "@data-slot/dialog";
const dialog = createDialog(element, {
defaultOpen: false,
closeOnClickOutside: true,
closeOnEscape: true,
lockScroll: true,
onOpenChange: (open) => console.log(open),
});
Slots
Runtime Slots
dialog- Root element that manages the open state and receives dialog events.dialog-trigger- Optional button that toggles the dialog.dialog-portal- Optional wrapper moved todocument.bodywhile the dialog is open.dialog-overlay- Required backdrop; clicking it dismisses the dialog when outside-click dismissal is enabled.dialog-content- Required modal panel with dialog semantics and focus management.dialog-title- Optional title used for the panel’saria-labelledby.dialog-description- Optional description used for the panel’saria-describedby.dialog-close- Optional button that closes the dialog; multiple close buttons are supported.
Markup
<div data-slot="dialog">
<button data-slot="dialog-trigger">Open</button>
<div data-slot="dialog-overlay" hidden></div>
<div data-slot="dialog-content" role="dialog">
<h2 data-slot="dialog-title">Title</h2>
<p data-slot="dialog-description">Description</p>
<button data-slot="dialog-close">Close</button>
</div>
</div>
Data Attributes
Options can also be set via data attributes on the root element. JS options take precedence over data attributes.
| Attribute | Type | Default | Description |
|---|---|---|---|
data-default-open |
boolean | false |
Initial open state |
data-close-on-click-outside |
boolean | true |
Close when clicking outside content |
data-close-on-escape |
boolean | true |
Close when pressing Escape |
data-lock-scroll |
boolean | true |
Lock body scroll when open |
data-alert-dialog |
boolean | false |
Use alertdialog role for confirmations |
Boolean attributes: present or "true" = true, "false" = false, absent = default.
<!-- Disable close on Escape -->
<div data-slot="dialog" data-close-on-escape="false">
...
</div>
<!-- Alert dialog that stays open when clicking outside -->
<div data-slot="dialog" data-alert-dialog data-close-on-click-outside="false">
...
</div>
Options
| Option | Type | Default | Description |
|---|---|---|---|
defaultOpen |
boolean |
false |
Initial open state |
closeOnClickOutside |
boolean |
true |
Close when clicking outside content |
closeOnEscape |
boolean |
true |
Close when pressing Escape |
lockScroll |
boolean |
true |
Lock body scroll when open |
alertDialog |
boolean |
false |
Use alertdialog role for confirmations |
onOpenChange |
(open: boolean) => void |
undefined |
Callback when open state changes |
Controller
| Method/Property | Description |
|---|---|
open() |
Open the dialog |
close() |
Close the dialog |
toggle() |
Toggle the dialog |
isOpen |
Current open state (readonly boolean) |
destroy() |
Cleanup all event listeners |
Controller Destruction
destroy() permanently disposes the controller and hides any open surface without
emitting an additional change event. Repeated destruction is safe; methods on the
old controller become no-ops. Create a new controller on the same root to rebind it.
Destruction restores prior focus, falling back to a surviving trigger if the prior target was removed. Destroying an unopened modal does not move focus.
Events
Outbound Events
Listen for changes via custom events:
element.addEventListener("dialog:change", (e) => {
console.log("Dialog open:", e.detail.open);
});
Inbound Events
Control the dialog via events:
| Event | Detail | Description |
|---|---|---|
dialog:set |
{ open: boolean } |
Set open state programmatically |
// Open the dialog
element.dispatchEvent(
new CustomEvent("dialog:set", { detail: { open: true } })
);
// Close the dialog
element.dispatchEvent(
new CustomEvent("dialog:set", { detail: { open: false } })
);
Deprecated Shapes
The following shape is deprecated and will be removed in v1.0:
// Deprecated: { value: boolean }
element.dispatchEvent(
new CustomEvent("dialog:set", { detail: { value: true } })
);
Use { open: boolean } instead.
Styling
Dialog exposes both data-state="open|closed" and popup-style animation hooks:
data-open/data-closedondialog,dialog-portal,dialog-overlay, anddialog-contentdata-starting-stylewhile openingdata-ending-stylewhile closing
Use data-open / data-closed with data-starting-style / data-ending-style for animations:
/* Backdrop */
[data-slot="dialog-overlay"] {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity 0.2s ease;
}
[data-slot="dialog-overlay"][data-open] {
opacity: 1;
}
[data-slot="dialog-content"] {
opacity: 0;
transform: translate(-50%, -50%) scale(0.95);
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
[data-slot="dialog-content"][data-open] {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
[data-slot="dialog-overlay"][data-starting-style],
[data-slot="dialog-overlay"][data-ending-style] {
opacity: 0;
}
[data-slot="dialog-content"][data-starting-style],
[data-slot="dialog-content"][data-ending-style] {
opacity: 0;
transform: translate(-50%, -50%) scale(0.95);
}
Stacking is intentionally not hardcoded in JavaScript. Configure z-index in your CSS.
When multiple dialogs are open, data-stack-index and CSS variables are exposed on
dialog-overlay and dialog-content:
data-stack-index--dialog-stack-index--dialog-overlay-stack-index(overlay)--dialog-content-stack-index(content)
With Tailwind:
<div
data-slot="dialog-overlay"
class="fixed inset-0 bg-black/50 opacity-0 transition-opacity duration-200 data-[open]:opacity-100 data-[starting-style]:opacity-0 data-[ending-style]:opacity-0"
></div>
<div
data-slot="dialog-content"
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white p-6 opacity-0 scale-95 transition-all duration-200 data-[open]:opacity-100 data-[open]:scale-100 data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95"
>
<!-- Dialog content -->
</div>
Keyboard Navigation
| Key | Action |
|---|---|
Escape |
Close dialog |
Tab |
Cycle focus within dialog |
Shift+Tab |
Cycle focus backwards |
Accessibility
The component automatically handles:
role="dialog"on contentaria-modal="true"on contentaria-labelledbylinked to titlearia-describedbylinked to descriptionaria-haspopup="dialog"on triggeraria-expandedstate on trigger- Focus trap within dialog
- Focus restoration on close