Dialog
@ailuracode/alpine-dialog
Headless accessible dialog store for Alpine.js — open/close state, focus trap, scroll lock integration, and ARIA helpers. No markup or CSS included.
Install
pnpm add @ailuracode/alpine-dialog @ailuracode/alpine-core alpinejsQuick start
import Alpine from "alpinejs";import { dialogPlugin } from "@ailuracode/alpine-dialog";import { scrollPlugin } from "@ailuracode/alpine-scroll";
Alpine.plugin(scrollPlugin());Alpine.plugin( dialogPlugin({ scroll: Alpine.store("scroll"), }));Alpine.start();Store API
// Open / close / toggle$store.dialog.open("settings");$store.dialog.close("settings");$store.dialog.toggle("settings");$store.dialog.isOpen("settings");
// Register / unregister instances$store.dialog.register("confirm", { closeOnEscape: true, scrollLock: true });$store.dialog.unregister("confirm");
// Accessibility helpers$store.dialog.bindContainer("settings", containerEl);$store.dialog.handleKeydown("settings", event);$store.dialog.handleOutsideClick("settings", event);$store.dialog.dialogProps("settings");// → { role: "dialog", "aria-modal": true, "aria-labelledby": ..., "aria-describedby": ... }
// Cleanup$store.dialog.destroy();Options
dialogPlugin({ id?: string, // controller identifier closeOnEscape?: boolean, // default: true closeOnOutsideClick?: boolean, // default: true scrollLock?: boolean, // default: true scroll?: ScrollStore, // optional @ailuracode/alpine-scroll store storeKey?: string, // Alpine store key (default: "dialog")});Avoiding name collisions
If your application already owns a $store.dialog — or another toolkit plugin registers on that name — rename the integration surface without touching the controller:
Alpine.plugin(dialogPlugin({ storeKey: "modal" })); // → $store.modalThe exposed constant DEFAULT_DIALOG_STORE_KEY keeps the rename discoverable from TypeScript.
Standalone usage (no Alpine)
import { createDialogController } from "@ailuracode/alpine-dialog";
const controller = createDialogController({ scrollLock: true });controller.register("my-dialog");controller.open("my-dialog");controller.isOpen("my-dialog"); // truecontroller.close("my-dialog");controller.destroy();Use createDialogStore() for a store-shaped object without Alpine, or createDialogStoreFromController(controller) when wiring a custom adapter.
| Controller API | Description |
|---|---|
hasInstance(id) |
Whether a dialog id is registered |
snapshotInstances() |
Shallow readonly copies for adapter sync |
isOpen(id) |
Query open state |
The controller emits open, close, and change events. The Alpine plugin mirrors snapshots into $store.dialog.instances.
Architecture
DialogController owns all mutable state. $store.dialog.instances is a reactive mirror updated on open, close, and change. Mutating store snapshots directly does not change controller state.
Migration
| Removed / changed | Replacement |
|---|---|
controller.instances getter |
snapshotInstances() or hasInstance(id) |
controller.toStore() |
createDialogStore() or createDialogStoreFromController(controller) |
Basic markup
<div x-data x-init="$store.dialog.register('settings')" @keydown.window="$store.dialog.handleKeydown('settings', $event)"> <button @click="$store.dialog.open('settings', { trigger: $event.target })"> Settings </button>
<template x-teleport="body"> <div x-show="$store.dialog.isOpen('settings')" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" > <div x-bind="$store.dialog.dialogProps('settings')" x-init="$store.dialog.bindContainer('settings', $el)" @click.stop > <h2 id="settings-title">Settings</h2> <p id="settings-desc">Update your preferences.</p> <button @click="$store.dialog.close('settings')">Close</button> </div> </div> </template></div>Accessibility
role="dialog"andaria-modal="true"viadialogProps()- Focus trap activates when the container is bound and the dialog opens
- Focus restores to the trigger element on close
- Escape dismisses when enabled
SSR
State is in-memory. Guard DOM bindings (bindContainer, focus trap) behind x-init or client-only wrappers.
Integration
- Scroll — pass
$store.scrollasscroll - Toast — show confirmation toasts after dialog actions in your UI layer (not a required dependency)
Limitations
- Stacking/z-index is consumer-owned — wrap modals in
<template x-teleport="body">when insideoverflow-hiddenancestors (x-teleportrequires a<template>tag in Alpine 3) - One focus trap per dialog id; bind the dialog panel root element
License
MIT
