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" attrs="@click='closeDialog()'" /}}
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" attrs="@click='closeDialog()'" /}}
</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 border border-foreground bg-background p-6 {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 <button
35 type="button"
36 class="focus-visible:border-focus-text focus-visible:outline-focus active:border-focus active:bg-focus active:text-focus-text inline-flex cursor-pointer items-center justify-center gap-2 border-2 border-transparent px-3 py-2 text-sm font-medium whitespace-nowrap transition-colors hover:border-current focus-visible:outline focus-visible:outline-4"
37 x-ref="trigger"
38 :id="$id('dialog-trigger')"
39 :aria-expanded="isOpen"
40 :aria-controls="$id('dialog-panel')"
41 @click="openDialog()"
42 >
43 {{ if label }}
44 {{ label }}
45 {{ elseif slot:trigger }}
46 {{ slot:trigger }}
47 {{ else }}
48 Open dialog
49 {{ /if }}
50 </button>
51 <div x-show="isOpen" x-transition.opacity.duration.200ms class="fixed inset-0 z-50" style="display: none">
52 <div class="bg-foreground/50 absolute inset-0" aria-hidden="true" @click="closeDialog()"></div>
53
54 <div class="relative flex min-h-full w-full p-4">
55 <div
56 x-ref="panel"
57 x-trap.inert.noscroll="isOpen"
58 x-transition.duration.200ms
59 class="{{ _class }}"
60 :id="$id('dialog-panel')"
61 role="dialog"
62 aria-modal="true"
63 aria-label="Dialog"
64 tabindex="-1"
65 @click.stop
66 >
67 <div class="absolute top-4 right-4">
68 {{ if slot:close }}
69 {{ slot:close }}
70 {{ else }}
71 <button
72 type="button"
73 class="text-muted-foreground focus-visible:border-focus-text focus-visible:outline-focus active:border-focus active:bg-focus active:text-focus-text inline-flex size-10 cursor-pointer items-center justify-center rounded-full border-2 border-transparent transition-colors hover:border-current focus-visible:outline focus-visible:outline-4"
74 aria-label="Close dialog"
75 @click="closeDialog()"
76 >
77 {{ svg src="icons/close" class="size-4" aria-hidden="true" }}
78 </button>
79 {{ /if }}
80 </div>
81 {{ slot }}
82 </div>
83 </div>
84 </div>
85</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 border border-foreground bg-background p-6 {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)"
>
<button
type="button"
class="focus-visible:border-focus-text focus-visible:outline-focus active:border-focus active:bg-focus active:text-focus-text inline-flex cursor-pointer items-center justify-center gap-2 border-2 border-transparent px-3 py-2 text-sm font-medium whitespace-nowrap transition-colors hover:border-current focus-visible:outline focus-visible:outline-4"
x-ref="trigger"
:id="$id('dialog-trigger')"
:aria-expanded="isOpen"
:aria-controls="$id('dialog-panel')"
@click="openDialog()"
>
{{ if label }}
{{ label }}
{{ elseif slot:trigger }}
{{ slot:trigger }}
{{ else }}
Open dialog
{{ /if }}
</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 }}
<button
type="button"
class="text-muted-foreground focus-visible:border-focus-text focus-visible:outline-focus active:border-focus active:bg-focus active:text-focus-text inline-flex size-10 cursor-pointer items-center justify-center rounded-full border-2 border-transparent transition-colors hover:border-current focus-visible:outline focus-visible:outline-4"
aria-label="Close dialog"
@click="closeDialog()"
>
{{ svg src="icons/close" class="size-4" aria-hidden="true" }}
</button>
{{ /if }}
</div>
{{ slot }}
</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