Skip to content
Layers
Esc
navigateopen⌘Jpreview
On this page

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-layers
pnpm add @stainless-code/alpine-layers
yarn add @stainless-code/alpine-layers
bun add @stainless-code/alpine-layers

Peer: 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.

Learn more

Last updated on September 4, 2026

Was this page helpful?