Open any layerfrom anywhere.
Stop prop-drilling isOpen and threading onConfirm callbacks through your tree.
Modals, drawers, toasts, and confirms as named, ordered stacks — open one from any component, effect, or route guard and await a typed result. Headless core, eight framework adapters.
import { LayerClient, createLayer, layerOptions } from "@stainless-code/layers";
const client = new LayerClient();
const confirm = layerOptions({
stack: "confirm",
key: ["confirm", "remove"],
component: renderConfirm, // your render fn
});
const c = createLayer(confirm, client);
async function remove() {
const ok = await c.open({ title: "Remove?" });
// ^? boolean
}import {
layerOptions,
StackProvider,
StackOutlet,
useLayer,
} from "@stainless-code/react-layers";
const confirm = layerOptions({
stack: "confirm",
key: ["confirm", "remove"],
component: ConfirmDialog,
});
function App() {
return (
<StackProvider>
<StackOutlet stack="confirm" />
<RemoveButton />
</StackProvider>
);
}
function RemoveButton() {
const c = useLayer(confirm);
async function remove() {
const ok = await c.open({ title: "Remove?" });
// ^? boolean
}
return <button onClick={() => void remove()}>Remove</button>;
}import {
layerOptions,
StackProvider,
StackOutlet,
useLayer,
} from "@stainless-code/preact-layers";
const confirm = layerOptions({
stack: "confirm",
key: ["confirm", "remove"],
component: ConfirmDialog,
});
function App() {
return (
<StackProvider>
<StackOutlet stack="confirm" />
<RemoveButton />
</StackProvider>
);
}
function RemoveButton() {
const c = useLayer(confirm);
async function remove() {
const ok = await c.open({ title: "Remove?" });
// ^? boolean
}
return <button onClick={() => void remove()}>Remove</button>;
}import {
LayerClient,
LayerClientContext,
StackOutlet,
layerOptions,
useLayer,
} from "@stainless-code/solid-layers";
const confirm = layerOptions({
stack: "confirm",
key: ["confirm", "remove"],
component: ConfirmDialog,
});
const client = new LayerClient();
function App() {
return (
<LayerClientContext.Provider value={client}>
<StackOutlet stack="confirm" />
<RemoveButton />
</LayerClientContext.Provider>
);
}
function RemoveButton() {
const c = useLayer(confirm);
async function remove() {
const ok = await c.open({ title: "Remove?" });
// ^? boolean
}
return <button onClick={() => void remove()}>Remove</button>;
}import { Component, ViewContainerRef, inject } from "@angular/core";
import {
layerOptions,
provideLayerClient,
renderStack,
injectLayer,
} from "@stainless-code/angular-layers";
const confirm = layerOptions({
stack: "confirm",
key: ["confirm", "remove"],
component: ConfirmDialogComponent,
});
@Component({
selector: "app-root",
providers: [provideLayerClient()],
template: `<button (click)="remove()">Remove</button><ng-container #outlet />`,
})
export class AppComponent {
private c = injectLayer(confirm);
private vcr = inject(ViewContainerRef);
constructor() {
renderStack(this.vcr, "confirm");
}
async remove() {
const ok = await this.c.open({ title: "Remove?" });
// ^? boolean
}
}<script setup lang="ts">
import {
layerOptions,
provideLayerClient,
StackOutlet,
useLayer,
} from "@stainless-code/vue-layers";
const confirm = layerOptions({
stack: "confirm",
key: ["confirm", "remove"],
component: ConfirmDialog,
});
provideLayerClient();
const c = useLayer(confirm);
async function remove() {
const ok = await c.open({ title: "Remove?" });
// ^? boolean
}
</script>
<template>
<StackOutlet stack="confirm" />
<button @click="remove">Remove</button>
</template>import {
createLayer,
defineStackElements,
layerOptions,
provideLayerClient,
useLayer,
} from "@stainless-code/lit-layers";
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
defineStackElements();
const confirm = layerOptions({
stack: "confirm",
key: ["confirm", "remove"],
component: ConfirmDialog,
});
@customElement("app-shell")
class AppShell extends LitElement {
// Field order: provide before useLayer (same-host context is not the path).
#client = provideLayerClient(this);
#confirm = useLayer(this, confirm, this.#client);
createRenderRoot() {
return this;
}
render() {
return html`
<button
type="button"
@click=${() => void this.#confirm.open({ title: "Remove?" })}
>
Remove
</button>
<stack-outlet stack="confirm"></stack-outlet>
`;
}
}<script type="module">
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 c = createLayer(confirm);
Alpine.data("demo", () => ({
async remove() {
const ok = await c.open({ title: "Remove?" });
// ^? boolean
},
}));
window.Alpine = Alpine;
Alpine.start();
</script>
<div x-data="demo">
<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>
</div>
</template>
<button type="button" @click="remove()">Remove</button>
</div><script lang="ts">
import {
createLayer,
layerOptions,
setLayerClient,
useStack,
} from "@stainless-code/svelte-layers";
const confirm = layerOptions({
stack: "confirm",
key: ["confirm", "remove"],
});
setLayerClient();
const stack = useStack({ stack: "confirm" });
const c = createLayer(confirm);
async function remove() {
const ok = await c.open({ title: "Remove?" });
// ^? boolean
}
</script>
{#each stack.current as state (state.id)}
{@const call = stack.callFor(state)}
<!-- render your dialog with call + state.payload -->
{/each}
<button onclick={remove}>Remove</button>bun add @stainless-code/layersbun add @stainless-code/react-layersbun add @stainless-code/preact-layersbun add @stainless-code/solid-layersbun add @stainless-code/angular-layersbun add @stainless-code/vue-layersbun add @stainless-code/lit-layersbun add @stainless-code/alpine-layersbun add @stainless-code/svelte-layersTry it live.
Modals are just async functions you forgot to await. Open a confirm, toast, or serial flow below and watch the stack and lifecycle update live in the inspector.
When to use it
When to reach for Layers.
Overlay state is cross-cutting, but component models push it local. Layers moves it into a client you invoke imperatively — declare once, open from anywhere.
| Use case | What it involves | Fit |
|---|---|---|
| Confirm/prompt that returns a value | await open + DataTag response inference | Ideal |
| Open an overlay from non-UI code (route guard, event bus, effect, util) | global LayerClient.open | Ideal |
| Toast / snackbar / progress — one instance, updated live | upsert + update | Ideal |
| One-at-a-time / sequential flows (onboarding, queued confirms) | serial scope + getQueuedSnapshot | Ideal |
| Stacked or nested overlays (drawer → sub-dialog) | layer groups (useLayerGroup / createLayerGroup) | Ideal |
| Guard dismissal ("discard unsaved changes?") | blockers (addBlocker, dismissing, dismissAll) | Ideal |
| Enter/exit animated overlays | transition + enteringDelay/exitingDelay settle() | Good |
| Validate an untrusted payload at open | validate (Standard Schema or a function) | Nice-to-have |
| One overlay system across React/Preact/Solid/Angular/Vue/Lit/Alpine/Svelte | UI-agnostic core + adapters | Ideal |
When to skip it.
A single local dialog with no return value is simpler as a controlled component. Layers pays for itself when overlay state crosses boundaries, stacks, queues, or needs a typed await.
| Use case | Fit |
|---|---|
| A single, always-local overlay — no return, stacking, queue, animation, or guard, and no wish for a global registry | Skip → plain controlled component |
| Static/inline content with no overlay semantics; full-page navigation | Skip → plain markup / a router |
One core, eight adapters.
Declare a layer once, then open and await it — same engine, your framework's reactivity.
import { LayerClient, createLayer, layerOptions } from "@stainless-code/layers";
const client = new LayerClient();
const confirm = layerOptions({
stack: "confirm",
key: ["confirm", "remove"],
component: renderConfirm, // your render fn
});
const c = createLayer(confirm, client);
async function remove() {
const ok = await c.open({ title: "Remove?" });
// ^? boolean
}import {
layerOptions,
StackProvider,
StackOutlet,
useLayer,
} from "@stainless-code/react-layers";
const confirm = layerOptions({
stack: "confirm",
key: ["confirm", "remove"],
component: ConfirmDialog,
});
function App() {
return (
<StackProvider>
<StackOutlet stack="confirm" />
<RemoveButton />
</StackProvider>
);
}
function RemoveButton() {
const c = useLayer(confirm);
async function remove() {
const ok = await c.open({ title: "Remove?" });
// ^? boolean
}
return <button onClick={() => void remove()}>Remove</button>;
}import {
layerOptions,
StackProvider,
StackOutlet,
useLayer,
} from "@stainless-code/preact-layers";
const confirm = layerOptions({
stack: "confirm",
key: ["confirm", "remove"],
component: ConfirmDialog,
});
function App() {
return (
<StackProvider>
<StackOutlet stack="confirm" />
<RemoveButton />
</StackProvider>
);
}
function RemoveButton() {
const c = useLayer(confirm);
async function remove() {
const ok = await c.open({ title: "Remove?" });
// ^? boolean
}
return <button onClick={() => void remove()}>Remove</button>;
}import {
LayerClient,
LayerClientContext,
StackOutlet,
layerOptions,
useLayer,
} from "@stainless-code/solid-layers";
const confirm = layerOptions({
stack: "confirm",
key: ["confirm", "remove"],
component: ConfirmDialog,
});
const client = new LayerClient();
function App() {
return (
<LayerClientContext.Provider value={client}>
<StackOutlet stack="confirm" />
<RemoveButton />
</LayerClientContext.Provider>
);
}
function RemoveButton() {
const c = useLayer(confirm);
async function remove() {
const ok = await c.open({ title: "Remove?" });
// ^? boolean
}
return <button onClick={() => void remove()}>Remove</button>;
}import { Component, ViewContainerRef, inject } from "@angular/core";
import {
layerOptions,
provideLayerClient,
renderStack,
injectLayer,
} from "@stainless-code/angular-layers";
const confirm = layerOptions({
stack: "confirm",
key: ["confirm", "remove"],
component: ConfirmDialogComponent,
});
@Component({
selector: "app-root",
providers: [provideLayerClient()],
template: `<button (click)="remove()">Remove</button><ng-container #outlet />`,
})
export class AppComponent {
private c = injectLayer(confirm);
private vcr = inject(ViewContainerRef);
constructor() {
renderStack(this.vcr, "confirm");
}
async remove() {
const ok = await this.c.open({ title: "Remove?" });
// ^? boolean
}
}<script setup lang="ts">
import {
layerOptions,
provideLayerClient,
StackOutlet,
useLayer,
} from "@stainless-code/vue-layers";
const confirm = layerOptions({
stack: "confirm",
key: ["confirm", "remove"],
component: ConfirmDialog,
});
provideLayerClient();
const c = useLayer(confirm);
async function remove() {
const ok = await c.open({ title: "Remove?" });
// ^? boolean
}
</script>
<template>
<StackOutlet stack="confirm" />
<button @click="remove">Remove</button>
</template>import {
createLayer,
defineStackElements,
layerOptions,
provideLayerClient,
useLayer,
} from "@stainless-code/lit-layers";
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
defineStackElements();
const confirm = layerOptions({
stack: "confirm",
key: ["confirm", "remove"],
component: ConfirmDialog,
});
@customElement("app-shell")
class AppShell extends LitElement {
// Field order: provide before useLayer (same-host context is not the path).
#client = provideLayerClient(this);
#confirm = useLayer(this, confirm, this.#client);
createRenderRoot() {
return this;
}
render() {
return html`
<button
type="button"
@click=${() => void this.#confirm.open({ title: "Remove?" })}
>
Remove
</button>
<stack-outlet stack="confirm"></stack-outlet>
`;
}
}<script type="module">
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 c = createLayer(confirm);
Alpine.data("demo", () => ({
async remove() {
const ok = await c.open({ title: "Remove?" });
// ^? boolean
},
}));
window.Alpine = Alpine;
Alpine.start();
</script>
<div x-data="demo">
<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>
</div>
</template>
<button type="button" @click="remove()">Remove</button>
</div><script lang="ts">
import {
createLayer,
layerOptions,
setLayerClient,
useStack,
} from "@stainless-code/svelte-layers";
const confirm = layerOptions({
stack: "confirm",
key: ["confirm", "remove"],
});
setLayerClient();
const stack = useStack({ stack: "confirm" });
const c = createLayer(confirm);
async function remove() {
const ok = await c.open({ title: "Remove?" });
// ^? boolean
}
</script>
{#each stack.current as state (state.id)}
{@const call = stack.callFor(state)}
<!-- render your dialog with call + state.payload -->
{/each}
<button onclick={remove}>Remove</button><script lang="ts">
import {
callFor,
createLayer,
layerOptions,
setLayerClient,
useLayerClient,
useStack,
} from "@stainless-code/svelte-layers/store";
const confirm = layerOptions({
stack: "confirm",
key: ["confirm", "remove"],
});
setLayerClient();
const client = useLayerClient();
const stack = useStack({ stack: "confirm" });
const c = createLayer(confirm);
async function remove() {
const ok = await c.open({ title: "Remove?" });
// ^? boolean
}
</script>
{#each $stack as state (state.id)}
{@const call = callFor(client, "confirm", state)}
<!-- render your dialog with call + state.payload -->
{/each}
<button onclick={remove}>Remove</button>Svelte ships two modes — runes and store.
Every adapter, the same API.
React, Preact, Solid, Angular, Vue, Lit, and Alpine re-export the full core; Svelte re-exports selectively. Angular, Alpine, and Svelte diverge by design — imperative / markup outlets instead of React-shaped hosts.
| Capability | React | Preact | Solid | Angular | Vue | Lit | Alpine | Svelte (runes) | Svelte (store) |
|---|---|---|---|---|---|---|---|---|---|
| Primitives + core re-export | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| StackOutlet (built-in host) | ✓ | ✓ | ✓ | ø | ✓ | ✓ | ✓ | ø | ø |
| useLayerGroup | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| useMutationFlow | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| createStackHook | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ø | ø |
Batteries included.
Handle the overlay cases that stop being local: ordered stacks, one-at-a-time flows, nested dialogs, guarded closes, and validated input. Adopt only the pieces your UI needs.
Partition overlays into ordered stacks
Open and update from anywhere
isOpen state.Await a result — no call-site generics
await confirm.open({ title }) on a wired handle — R flows from the key brand.Run flows one at a time
scope: { strategy: 'serial' } and a snapshot of what's waiting.Nest overlays without prop-drilling
useLayerGroup / createLayerGroup.Animate enter and exit
call.settle() for animated overlays.Guard against accidental close
addBlocker, dismissing, and dismissAll modes.Validate payloads at open
open with Standard Schema or a sync function — rejects with PayloadValidationError.No framework in the core
LayerClient engine — framework adapters subscribe via their native reactivity primitives.Start opening layers.
Install one adapter — the core ships as its dependency and is re-exported.
bun add @stainless-code/layers