Kern Component Library GitHub GitHub

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 }}

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 }}

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>

Dependencies

Packages

1composer require marcorieser/tailwind-merge-statamic
2npm install alpinejs @alpinejs/focus

Internal dependency graph

Current

Dialog

/primitives/dialog

Transitive

None