Alpine
Alpine adapter — plugin magics, x-layer-outlet, Rank-2 layerStack + callFor.
Stop wiring x-show / x-data open flags for every modal — open a named stack from anywhere and await a typed result. @stainless-code/alpine-layers is the Alpine plugin + outlet; the core is re-exported.
Install
npm install @stainless-code/alpine-layerspnpm add @stainless-code/alpine-layersyarn add @stainless-code/alpine-layersbun add @stainless-code/alpine-layersPeer: alpinejs >=3.13
Optional peer: @alpinejs/focus >=3.13 — focus trap / inert backdrop (x-trap.inert.noscroll)
Register the plugin
Vite / bundled app — register before Alpine.start():
import Alpine from "alpinejs";
import layers from "@stainless-code/alpine-layers";
Alpine.plugin(layers);
window.Alpine = Alpine;
Alpine.start();
CDN — load the bootstrap before Alpine core (registers on alpine:init):
<script
type="module"
src="https://cdn.jsdelivr.net/npm/@stainless-code/alpine-layers/cdn"
></script>
<script
defer
src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"
></script>
The plugin registers $layers, $layer, x-layer-outlet, and Alpine.data('layerStack', …).
Client
Hold the client in the plugin closure — not Alpine.store. getLayerClient() lazy-inits on first access. Call setLayerClient(client?) only when you need to pin a custom client before first use (creates a LayerClient when omitted). createStackHook({ client }) calls setLayerClient for you.
import { setLayerClient } from "@stainless-code/alpine-layers";
setLayerClient(); // optional — only to pin a client before first stack use
Render a stack
Outlet — declare markup inside x-layer-outlet; the directive evaluates the expression as a stack id. Keys by state.id and exposes the active row as $layer (LayerComponentProps bag — $layer.call, $layer.payload, …).
Always quote the id: x-layer-outlet="'confirm'". Bare confirm resolves to window.confirm (a function) — the outlet warns in development and falls back to "default".
<template x-layer-outlet="'confirm'">
<div role="dialog">
<h2 x-text="$layer.payload.title"></h2>
<button type="button" @click="$layer.call.end(true)">Yes</button>
<button type="button" @click="$layer.call.end(false)">No</button>
</div>
</template>
Rank-2 (subscribe + render yourself — no component registry) — Alpine.data('layerStack', stackId?) returns reactive { states, callFor(state) }; pair with x-for where an outlet is not enough:
<div x-data="layerStack('confirm')">
<template x-for="state in states" :key="state.id">
<div role="dialog">
<h2 x-text="state.payload.title"></h2>
<button type="button" @click="callFor(state)?.end(true)">Yes</button>
</div>
</template>
</div>
layerOptions has no component field — Alpine markup lives in the outlet / loop.
Confirm dialog
<div x-data="demo">
<button type="button" @click="remove()">Remove</button>
<template x-layer-outlet="'confirm'">
<div role="dialog">
<h2 x-text="$layer.payload.title"></h2>
<button type="button" @click="$layer.call.end(true)">Yes</button>
<button type="button" @click="$layer.call.end(false)">No</button>
</div>
</template>
</div>
import Alpine from "alpinejs";
import layers, {
createLayer,
layerOptions,
setLayerClient,
} from "@stainless-code/alpine-layers";
Alpine.plugin(layers);
setLayerClient();
const confirm = layerOptions<{ title: string }, boolean>({
stack: "confirm",
key: ["confirm", "remove"],
});
const confirmHandle = createLayer(confirm);
async function remove() {
const ok = await confirmHandle.open({ title: "Remove item?" });
// ^? boolean
}
Alpine.data("demo", () => ({ remove }));
window.Alpine = Alpine;
Alpine.start();import Alpine from "alpinejs";
import layers, {
createLayer,
layerOptions,
setLayerClient,
} from "@stainless-code/alpine-layers";
Alpine.plugin(layers);
setLayerClient();
const confirm = layerOptions({
stack: "confirm",
key: ["confirm", "remove"],
});
const confirmHandle = createLayer(confirm);
async function remove() {
const ok = await confirmHandle.open({ title: "Remove item?" });
// ^? boolean
}
Alpine.data("demo", () => ({ remove }));
window.Alpine = Alpine;
Alpine.start();Prefer createLayer / confirmHandle.open from JS. $layers.open is the template-reachable facade over client.open (it lives in Alpine scope; a module-local client does not). Pass a full options bag that is in scope (e.g. put confirm on Alpine.data). For strict CSP, pair Alpine with @alpinejs/csp.
Primitives
Options bag plus optional trailing LayerClient. Drive with createLayer(options, client?); observe with createLayerState({ key, … }). Core headless createLayer is re-exported as createLayerHandle.
| Export | Role |
|---|---|
getLayerClient() / setLayerClient(client?) |
Singleton client in plugin closure (not Alpine.store); get lazy-inits; set only before first use |
useStack({ stack?, select?, compare? }, client?) |
Subscribe stack slice into Alpine reactivity |
useQueuedStack({ … }, client?) |
Queued snapshot binding |
createLayer(options, client?) |
Drive — wired handle + reactive state / queued / top |
createLayerState({ key, stack?, select?, compare? }, client?) |
Observe — mounted same-key layers |
createLayerQueuedState({ key, … }, client?) |
Observe — queued same-key layers |
Ergonomic APIs
| Export / surface | Role |
|---|---|
$layers |
Magic facade — open, dismissAll, getStack (per-layer dismiss via $layer.call.dismiss) |
$layer |
Current outlet row as LayerComponentProps inside x-layer-outlet |
x-layer-outlet="'<stackId>'" |
<template> outlet; id-keyed, directive effect + cleanup |
Alpine.data('layerStack', id?) |
Rank-2 { states, callFor(state) } + subscribe teardown in destroy() |
useMutationFlow(call) |
Reactive pending; run(fn).orEnd(response) |
useLayerGroup(call, options?) |
{ open, dismissAll, states, stackId, dispose } — nest x-layer-outlet in markup; call dispose() when tearing down manually |
createStackHook(config?) |
{ setClient, useAppStack } |
No component registry — markup stays in your templates. Optional Alpine.data('confirm', …) is for reusable behavior, not template registration.