Dialog
Accessible dialog primitive with backdrop, focus trap, and scroll lock. Requires @alpinejs/focus. Panel positioning and sizing is the consumer's responsibility via the `class` prop.
Requires Alpine.js
Default
1{{ partial:components/primitives/dialog label="Open dialog" }}
2 {{ partial:components/primitives/heading level="h4" content="Dialog Title" /}}
3 <p class="text-muted-foreground mt-2 text-sm">
4 Dialog body content goes here. Focus is trapped inside until the dialog closes.
5 </p>
6 <div class="mt-6 flex justify-end gap-2">
7 {{ partial:components/primitives/button label="Confirm" size="sm" /}}
8 {{ partial:components/primitives/button label="Cancel" intent="ghost" size="sm" /}}
9 </div>
10{{ /partial:components/primitives/dialog }}
{{ partial:components/primitives/dialog label="Open dialog" }}
{{ partial:components/primitives/heading level="h4" content="Dialog Title" /}}
<p class="text-muted-foreground mt-2 text-sm">
Dialog body content goes here. Focus is trapped inside until the dialog closes.
</p>
<div class="mt-6 flex justify-end gap-2">
{{ partial:components/primitives/button label="Confirm" size="sm" /}}
{{ partial:components/primitives/button label="Cancel" intent="ghost" size="sm" /}}
</div>
{{ /partial:components/primitives/dialog }}
Custom Trigger
1{{ partial:components/primitives/dialog }}
2 {{ slot:trigger }}
3 {{ svg src="icons/star" class="size-4 shrink-0" aria-hidden="true" }}
4 Open with icon
5 {{ /slot:trigger }}
6 {{ partial:components/primitives/heading level="h4" content="Custom Trigger" /}}
7 <p class="text-muted-foreground mt-2 text-sm">
8 Use
9 <code>slot:trigger</code>
10 for any HTML content in the trigger button.
11 </p>
12{{ /partial:components/primitives/dialog }}
{{ partial:components/primitives/dialog }}
{{ slot:trigger }}
{{ svg src="icons/star" class="size-4 shrink-0" aria-hidden="true" }}
Open with icon
{{ /slot:trigger }}
{{ partial:components/primitives/heading level="h4" content="Custom Trigger" /}}
<p class="text-muted-foreground mt-2 text-sm">
Use
<code>slot:trigger</code>
for any HTML content in the trigger button.
</p>
{{ /partial:components/primitives/dialog }}
Props
| Name | Type | Default | Description |
|---|---|---|---|
label
|
string
|
Trigger button label text (falls back to slot:trigger, then "Open dialog") | |
class
|
string
|
Additional classes merged via tw_merge on the dialog panel (positioning, sizing) |
Slots
| Name | Fallback / Default | Description |
|---|---|---|
default
|
Dialog body content | |
close
|
an icon button with close icon
|
Custom close button (defaults to an icon button with close icon) |
trigger
|
label
|
Trigger button content as HTML (overrides label) |
Source
1{{#
2 @name Dialog
3 @desc Accessible dialog primitive with backdrop, focus trap, and scroll lock. Requires @alpinejs/focus. Panel positioning and sizing is the consumer's responsibility via the `class` prop.
4 @param label string - Trigger button label text (falls back to slot:trigger, then "Open dialog")
5 @param class string - Additional classes merged via tw_merge on the dialog panel (positioning, sizing)
6 @slot trigger - Trigger button content as HTML (overrides label)
7 @slot default - Dialog body content
8 @slot close - Custom close button (defaults to an icon button with close icon)
9#}}
10{{ _class = 'relative mx-auto my-auto w-full max-w-lg rounded-lg border border-border bg-background/95 p-6 shadow-lg backdrop-blur-xl {class}'
11 | tw_merge }}
12<div
13 x-data="{
14 isOpen: false,
15 lastActiveElement: null,
16 openDialog() {
17 this.lastActiveElement = document.activeElement;
18 this.isOpen = true;
19 this.$nextTick(() => {
20 const firstFocusable = this.$refs.panel?.querySelector(`button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])`);
21 (firstFocusable || this.$refs.panel)?.focus();
22 });
23 },
24 closeDialog() {
25 if (!this.isOpen) return;
26 this.isOpen = false;
27 this.$nextTick(() => (this.$refs.trigger || this.lastActiveElement)?.focus());
28 }
29 }"
30 x-id="['dialog-trigger', 'dialog-panel']"
31 @keydown.escape.window="if (isOpen) closeDialog()"
32 x-effect="document.body.classList.toggle('overflow-hidden', isOpen)"
33>
34 {{ partial:components/primitives/button intent="ghost" class="h-auto border border-border bg-background px-3 py-2 text-sm aria-expanded:bg-secondary/70 hover:bg-secondary/50" }}
35 {{ slot:attrs }}
36 x-ref="trigger" :id="$id('dialog-trigger')" :aria-expanded="isOpen" :aria-controls="$id('dialog-panel')"
37 @click="openDialog()"
38 {{ /slot:attrs }}
39 {{ if label }}
40 {{ label }}
41 {{ elseif slot:trigger }}
42 {{ slot:trigger }}
43 {{ else }}
44 Open dialog
45 {{ /if }}
46 {{ /partial:components/primitives/button }}
47 <div x-show="isOpen" x-transition.opacity.duration.200ms class="fixed inset-0 z-50" style="display: none">
48 <div class="bg-foreground/50 absolute inset-0" aria-hidden="true" @click="closeDialog()"></div>
49
50 <div class="relative flex min-h-full w-full p-4">
51 <div
52 x-ref="panel"
53 x-trap.inert.noscroll="isOpen"
54 x-transition.duration.200ms
55 class="{{ _class }}"
56 :id="$id('dialog-panel')"
57 role="dialog"
58 aria-modal="true"
59 aria-label="Dialog"
60 tabindex="-1"
61 @click.stop
62 >
63 <div class="absolute top-4 right-4">
64 {{ if slot:close }}
65 {{ slot:close }}
66 {{ else }}
67 {{ partial:components/primitives/button intent="ghost" size="icon" class="h-auto w-auto p-1 text-muted-foreground hover:bg-secondary/70 hover:text-foreground" }}
68 {{ slot:attrs }}
69 aria-label="Close dialog" @click="closeDialog()"
70 {{ /slot:attrs }}
71 {{ svg src="icons/close" class="size-5" }}
72 {{ /partial:components/primitives/button }}
73 {{ /if }}
74 </div>
75
76 <div class="pr-10">
77 {{ slot }}
78 </div>
79 </div>
80 </div>
81 </div>
82</div>
{{#
@name Dialog
@desc Accessible dialog primitive with backdrop, focus trap, and scroll lock. Requires @alpinejs/focus. Panel positioning and sizing is the consumer's responsibility via the `class` prop.
@param label string - Trigger button label text (falls back to slot:trigger, then "Open dialog")
@param class string - Additional classes merged via tw_merge on the dialog panel (positioning, sizing)
@slot trigger - Trigger button content as HTML (overrides label)
@slot default - Dialog body content
@slot close - Custom close button (defaults to an icon button with close icon)
#}}
{{ _class = 'relative mx-auto my-auto w-full max-w-lg rounded-lg border border-border bg-background/95 p-6 shadow-lg backdrop-blur-xl {class}'
| tw_merge }}
<div
x-data="{
isOpen: false,
lastActiveElement: null,
openDialog() {
this.lastActiveElement = document.activeElement;
this.isOpen = true;
this.$nextTick(() => {
const firstFocusable = this.$refs.panel?.querySelector(`button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])`);
(firstFocusable || this.$refs.panel)?.focus();
});
},
closeDialog() {
if (!this.isOpen) return;
this.isOpen = false;
this.$nextTick(() => (this.$refs.trigger || this.lastActiveElement)?.focus());
}
}"
x-id="['dialog-trigger', 'dialog-panel']"
@keydown.escape.window="if (isOpen) closeDialog()"
x-effect="document.body.classList.toggle('overflow-hidden', isOpen)"
>
{{ partial:components/primitives/button intent="ghost" class="h-auto border border-border bg-background px-3 py-2 text-sm aria-expanded:bg-secondary/70 hover:bg-secondary/50" }}
{{ slot:attrs }}
x-ref="trigger" :id="$id('dialog-trigger')" :aria-expanded="isOpen" :aria-controls="$id('dialog-panel')"
@click="openDialog()"
{{ /slot:attrs }}
{{ if label }}
{{ label }}
{{ elseif slot:trigger }}
{{ slot:trigger }}
{{ else }}
Open dialog
{{ /if }}
{{ /partial:components/primitives/button }}
<div x-show="isOpen" x-transition.opacity.duration.200ms class="fixed inset-0 z-50" style="display: none">
<div class="bg-foreground/50 absolute inset-0" aria-hidden="true" @click="closeDialog()"></div>
<div class="relative flex min-h-full w-full p-4">
<div
x-ref="panel"
x-trap.inert.noscroll="isOpen"
x-transition.duration.200ms
class="{{ _class }}"
:id="$id('dialog-panel')"
role="dialog"
aria-modal="true"
aria-label="Dialog"
tabindex="-1"
@click.stop
>
<div class="absolute top-4 right-4">
{{ if slot:close }}
{{ slot:close }}
{{ else }}
{{ partial:components/primitives/button intent="ghost" size="icon" class="h-auto w-auto p-1 text-muted-foreground hover:bg-secondary/70 hover:text-foreground" }}
{{ slot:attrs }}
aria-label="Close dialog" @click="closeDialog()"
{{ /slot:attrs }}
{{ svg src="icons/close" class="size-5" }}
{{ /partial:components/primitives/button }}
{{ /if }}
</div>
<div class="pr-10">
{{ slot }}
</div>
</div>
</div>
</div>
</div>
Dependencies
Packages
1composer require marcorieser/tailwind-merge-statamic
2npm install alpinejs @alpinejs/focus
composer require marcorieser/tailwind-merge-statamic npm install alpinejs @alpinejs/focus