| name | layers |
| description | UI-agnostic layer stack engine for @stainless-code/layers. Use for opening layers imperatively from anywhere, ordered named stacks, typed await or fire-and-forget calls, lifecycle control, blockers, validation, singleton updates, serial or nested layers, multi-stack orchestration, or building a library/framework adapter. |
| license | MIT |
| keywords | ["tanstack-intent","headless","modal","dialog","stack","typescript"] |
| metadata | {"library":"@stainless-code/layers","library_version":"0.2.3","framework":"framework-agnostic"} |
| sources | ["https://stainless-code.com/layers/adapters/core","https://github.com/stainless-code/layers/blob/main/docs/architecture.md"] |
@stainless-code/layers core engine
@stainless-code/layers is the headless, UI-agnostic engine for opening any layer from anywhere and managing it as an ordered, named stack. An app-wide LayerClient owns stack state and lifecycle; awaiting a typed result is optional, and void client.open(...) is equally first-class.
The React, Preact, Solid, Angular, Vue, Lit, and Svelte adapters bind the engine to their library or framework's rendering model. All adapters have reached ergonomic parity; Angular and Svelte diverge by design — see adapter ergonomics.
When to use this skill
Reach for @stainless-code/layers directly when building a library or framework adapter, or driving overlay or stack state in a headless, non-UI context. App developers usually install their library or framework adapter instead; it re-exports this core.
Use it to open layers imperatively from anywhere and manage ordered, named stacks. Await a typed result or fire-and-forget; both are first-class. Singletons via upsert with live update, serial one-at-a-time queues, nested or child stacks, transitions, dismissal blockers, validation, and gcTime each stand on their own.
Skip the core directly when an app only needs overlay UI and an adapter exists—install the adapter. Skip the whole library for a single, always-local overlay with no return, stacking, queue, animation, or guard needs.
Full fit matrix: When to use Layers.
Install
bun add @stainless-code/layers
Usually this package is pulled in by an adapter, which also re-exports the core API.
Engine model
LayerClient
└── LayerStack[] (one observable stack per named surface)
└── Layer[] (ordered state + caller-facing promise)
LayerClient owns named stacks, each LayerStack exposes immutable snapshots, and each Layer owns one instance's state and completion promise. An adapter observes snapshots and binds a call context; application code calls open() and awaits that promise.
Quick start (core only)
import {
LayerClient,
layerOptions,
createCallContext,
type LayerStack,
} from "@stainless-code/layers";
type ConfirmPayload = { title: string };
type ConfirmResponse = boolean;
const client = new LayerClient();
const confirm = layerOptions<ConfirmPayload, ConfirmResponse>({
stack: "confirm",
key: ["confirm", "remove"],
});
const stack = client.getStack("confirm") as LayerStack<
ConfirmPayload,
ConfirmResponse
>;
stack.subscribe(() => {
for (const state of stack.getSnapshot()) {
const layer = stack.getLayer(state.id)!;
const call = createCallContext(stack, layer, state);
}
});
const ok = await client.open({
...confirm,
payload: { title: "Remove item?" },
});
Payload and response are both optional. This fire-and-forget layer has neither;
its renderer resolves the call with call.dismiss().
const maintenanceNotice = layerOptions<void>({
stack: "notice",
key: ["notice", "maintenance"],
});
const noticeStack = client.getStack("notice");
noticeStack.subscribe(() => {
for (const state of noticeStack.getSnapshot()) {
const layer = noticeStack.getLayer(state.id)!;
const call = createCallContext(noticeStack, layer, state);
void call.dismiss();
}
});
void client.open(maintenanceNotice);
Wired handles (createLayer)
For explicit-client / headless callers, createLayer(options, client) binds identity and returns a LayerHandle (or ValidatedLayerHandle when options.validate is set):
import { LayerClient, layerOptions, createLayer } from "@stainless-code/layers";
const client = new LayerClient();
const confirm = layerOptions<ConfirmPayload, ConfirmResponse>({
stack: "confirm",
key: ["confirm", "remove"],
});
const c = createLayer(confirm, client);
const ok = await c.open({ title: "Remove?" });
c.dismiss(false, { id: c.current?.id });
Stack-level ops via c.stack.*. current is live-checked. Validated I/O: glossary.
Key inference
layerOptions<P, R>(...) is a zero-runtime, spreadable options helper; it has no .call() method. It brands key with DataTag<Key, R, E>, so open() infers the response and error types. To brand only a key:
import { layerKey } from "@stainless-code/layers";
const removeKey = layerKey<ConfirmResponse>()(["confirm", "remove"]);
const ok = await client.open({
key: removeKey,
stack: "confirm",
payload: { title: "Remove?" },
});
The call context
createCallContext gives an active layer end, dismiss, addBlocker, update, setRunning, and settle, plus ended, index, stackSize, root, stackId, and layerId. await call.end(response) and await call.dismiss(response) request dismissal; when allowed, they resolve the caller and begin exit. Both return Promise<boolean>, with false meaning a blocker vetoed dismissal. update patches payload, setRunning controls actionStatus, and settle completes the current enter or exit transition.
key is logical identity for find, upsert, and gcTime; every mount gets a unique instance id shaped as ${hashKey(key)}#<n>. Keys must be JSON-safe (string | boolean | null | finite number | plain objects/arrays of those); hashKey / keySignature assert that domain and throw LayerKeyError otherwise. Parallel stacks may contain multiple same-key instances.
Lifecycle
The complete phase set is pending, queued, active, dismissed, and error. A loadFn starts at pending, receives { payload, signal }, then moves to active with data or to error and rejects the caller; dismissal aborts in-flight loading. A serial-waiting layer is queued and not mounted.
Animation is an independent transition axis: entering, settled, or exiting. Dismissal resolves the caller, marks the layer dismissed + exiting, and removes it after exitingDelay. For either direction, the delay or call.settle()—whichever happens first—completes the transition.
Guarding dismissal (blockers)
Gate user-intent dismissal with call.addBlocker for one instance or stack.addBlocker for stack policy. A blocker returning true allows dismissal; falsy, thrown, or rejected results veto it. Blockers may be async, including a nested confirmation:
call.addBlocker(async () =>
!dirty
? true
: await client.open({ ...confirm, payload: { title: "Discard?" } }),
);
dismissing is true while blockers run, and { force: true } bypasses them. stack.dismissAll(response, { mode }) supports skipBlocked, stopAtBlocked, and force; the default is skipBlocked, configurable per stack with dismissAllMode in defaultStackOptions. System teardown uses cancelAll (parent-dismiss child drain, group dispose, host disconnect) and rejects open() with LayerCancelledError — narrow with isLayerCancelledError. Do not conflate that with user dismiss / dismissAll(response), which still resolve with R.
Payload validation
Set validate on layerOptions or open to a StandardSchemaV1 or synchronous parser (input: unknown) => output. Validation runs synchronously when opening, and the parsed output becomes the layer payload. Invalid input rejects the returned promise with PayloadValidationError; narrow it with isPayloadValidationError and read issues: ReadonlyArray<ValidationIssue>. Async schemas are unsupported and reject with an Error.
import { isPayloadValidationError } from "@stainless-code/layers";
try {
await client.open({
...confirm,
payload: raw,
validate: (p) => parseConfirm(p),
});
} catch (err) {
if (isPayloadValidationError(err)) {
}
}
The layer error type defaults to DefaultLayerError. Set it application-wide through Register module augmentation:
declare module "@stainless-code/layers" {
interface Register {
defaultError: AppError;
}
}
Singleton + live updates
const toast = client.getStack("toast");
const completion = client.open({
stack: "toast",
key: ["toast", "export"],
upsert: true,
payload: { msg: "Starting…" },
});
const layer = toast.find(["toast", "export"])!;
toast.update(layer, { msg: "Done" });
await toast.dismiss(layer, undefined);
await completion;
Serial scope + gcTime
const serialClient = new LayerClient({
defaultStackOptions: {
confirm: { scope: { strategy: "serial" }, gcTime: 5_000 },
},
});
const stack = serialClient.getStack("confirm");
stack.getQueuedSnapshot();
stack.cancelQueued(["confirm", "remove"], false);
Serial scope allows only one occupying layer (pending, active, or error when onLoadError is "block", the default); later opens remain unmounted until their turn. With onLoadError: "advance", a rejecting loadFn removes the failed layer and drains the next queued open. cancelQueued resolves a waiting caller without mounting (FIFO, or { id } for exact). gcTime caches dismissed data after removal, so reopening the same key restores data without rerunning loadFn.
Nested layers
createLayerGroup(client, call, options?) creates a child stack scoped to its parent layer. Parent dismissal cancelAlls the child stack (LayerCancelledError, reason: "parentDismiss"); dispose() removes the lifetime binding when the owner unmounts.
import { createLayerGroup } from "@stainless-code/layers";
const group = createLayerGroup(client, call, { name: "nested" });
await group.open({
key: ["settings", "advanced"],
payload: { label: "Advanced" },
});
group.dismissAll();
group.dispose();
childStackId(call, name?) derives ${parentStackId}~${parentLayerId}~${name}.
Multi-stack
Use ensureStack(id, options?) to materialize a configured surface, getStackIds() to enumerate materialized stacks, and subscribeStacks(listener) to observe newly created stacks. LayerClient.dismissAll(stackId, response?, options?) completes open callers with a response; LayerClient.cancelAll(stackId?, { reason? }) force-clears and rejects with LayerCancelledError.
Public API
The complete public API surface:
| Kind | Exports |
|---|
| Engine classes | LayerClient, LayerStack, Layer |
| Infrastructure classes | Subscribable, ControlledPromise |
| Validation class | PayloadValidationError |
| Key errors | LayerKeyError, isLayerKeyError, assertLayerKey |
| Cancel errors | LayerCancelledError, isLayerCancelledError, LayerCancelReason |
| Declaration and inference | layerOptions, layerKey, DataTag, InferDataTagResponse, InferDataTagError, ResponseOf, ErrorOf |
| Wired handles | createLayer, LayerHandle, ValidatedLayerHandle |
| Rendering seam | createCallContext, LayerCallContext, LayerComponentProps, LayerComponent |
| Nested layers | createLayerGroup, childStackId, LayerGroupOptions, LayerGroupHandle |
| Identity and notification values | hashKey, keySignature, notifyManager |
| Validation API | isPayloadValidationError, Validator, InferValidatorInput, InferValidatorOutput, StandardSchemaV1, ValidationIssue |
| Layer model types | LayerKey, LayerPhase, LayerTransition, LayerActionStatus, LayerState, LayerOptions, OpenLayerOptions |
| Stack and client types | StackOptions, StackDefaults, LayerClientOptions |
| Dismissal types | BlockerFn, StackBlockerFn, DismissOptions, DismissAllOptions, DismissAllMode |
| Utility and configuration types | Resolve, Reject, PayloadArg, OmitKeyof, Register, DefaultLayerError |
Agnostic contract
Core never imports a UI library or framework. It leaves component rendering, reactivity bindings, hosts, and portals to consumers and adapters; LayerComponent is therefore unknown until an adapter narrows it.
See the architecture for the full core-adapter boundary and the repository README for package entry points.