Kern Component Library GitHub

Dialog

Accessible dialog primitive with backdrop, focus trap, and scroll locking.
Requires Alpine.js

Examples

Default preview

<div class="flex flex-wrap items-center gap-3">
{{ partial:components/primitives/dialog }}
{{ slot:trigger }}Open centered modal{{ /slot:trigger }}
<div class="space-y-4">
<div class="space-y-1">
<h3 class="text-lg font-semibold tracking-tight">Publish changes</h3>
<p class="text-muted-foreground text-sm">
This confirms your current content state and makes it visible to all visitors.
</p>
</div>
<div class="flex justify-end gap-2">
{{ partial:components/primitives/button label="Cancel" intent="ghost" size="sm" attributes="@click=closeDialog()" }}
{{ partial:components/primitives/button label="Publish" intent="primary" size="sm" attributes="@click=closeDialog()" }}
</div>
</div>
{{ /partial:components/primitives/dialog }}
{{ partial:components/primitives/dialog class="ml-auto mr-0 my-0 h-auto w-full max-w-sm self-stretch rounded-none rounded-l-lg border-l shadow-xl" }}
{{ slot:trigger }}Open drawer dialog{{ /slot:trigger }}
{{ slot:close }}
<button
type="button"
class="text-muted-foreground rounded-default px-2 py-1 text-xs font-medium transition-colors hover:bg-secondary/70 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@click="closeDialog()"
>
Close
</button>
{{ /slot:close }}
<div class="space-y-4">
<div class="space-y-1">
<h3 class="text-lg font-semibold tracking-tight">Drawer layout</h3>
<p class="text-muted-foreground text-sm">
Same dialog primitive, different
{{ partial:docs/components/inline-code class="px-1" }}class{{ /partial:docs/components/inline-code }}
value.
</p>
</div>
<div class="space-y-2">
<a href="#" class="block rounded-sm px-2 py-2 text-sm hover:bg-secondary/70">Overview</a>
<a href="#" class="block rounded-sm px-2 py-2 text-sm hover:bg-secondary/70">Members</a>
<a href="#" class="block rounded-sm px-2 py-2 text-sm hover:bg-secondary/70">Billing</a>
</div>
</div>
{{ /partial:components/primitives/dialog }}
</div>

Props

Name Type Default Description
class string Additional classes merged via tw_merge for dialog panel positioning/sizing

Source

{{#
@name Dialog
@desc Accessible dialog primitive with backdrop, focus trap, and scroll locking.
@param class string - Additional classes merged via tw_merge for dialog panel positioning/sizing
#}}
{{ _panel_classes = '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)"
>
<button
x-ref="trigger"
type="button"
class="rounded-default border-border bg-background text-foreground hover:bg-secondary/50 focus-visible:ring-ring focus-visible:ring-offset-background aria-expanded:bg-secondary/70 inline-flex items-center gap-2 border px-3 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
:id="$id('dialog-trigger')"
:aria-expanded="isOpen"
:aria-controls="$id('dialog-panel')"
@click="openDialog()"
>
{{ if 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="{{ _panel_classes }}"
: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 rounded-default hover:bg-secondary/70 hover:text-foreground focus-visible:ring-ring p-1 transition-colors focus-visible:ring-2 focus-visible:outline-none"
aria-label="Close dialog"
@click="closeDialog()"
>
{{ svg src="icons/close" class="size-5" }}
</button>
{{ /if }}
</div>
<div class="pr-10">
{{ slot }}
</div>
</div>
</div>
</div>
</div>

Dependencies

Packages

marcorieser/tailwind-merge-statamic

composer require marcorieser/tailwind-merge-statamic

@alpinejs/focus

npm install @alpinejs/focus

Internal dependencies

  • Uses: x-trap for focus trapping, x-id + $id() for unique trigger/panel IDs
  • Slots: trigger, default, close (optional)