@stainless-code/layers is a headless layer/stack manager: modals, dialogs, drawers, popovers, toasts, etc. are layers in named stacks. A UI-agnostic core (works with any UI library or framework) owns the engine; thin per-adapter packages render the active stack in each library or framework's reactivity.
The design is self-contained: an app-wide client owns named, ordered stacks of layers; each adapter subscribes to a stack's snapshot and renders the active layers in that library or framework's reactivity; a caller opens a layer from anywhere and may await its typed result.
For when to reach for this library (and when a plain component is simpler), see When to use Layers.
The seam is a published package boundary in a bun-workspaces monorepo: one zero-dep core package plus one adapter package per library or framework. Each adapter takes the core as a direct dependency and re-exports it, so a consumer installs only the package for their library or framework (npm i @stainless-code/react-layers) and core comes along transitively.
@stainless-code/layers core package (packages/core, zero-dep)
LayerClient ──┬── LayerStack (named, ordered) ── Layer[]
└── LayerClientOptions.defaultStackOptions
@stainless-code/<fw>-layers adapter package (packages/<fw>)
dependencies: @stainless-code/layers (workspace:* → resolved on publish)
peerDependencies: the library or framework (required)
binds LayerStack.subscribe/getSnapshot → UI reactivity
renders layers (StackOutlet, renderStack, or useStack().current — see § Adapter ergonomics)
- Core (
packages/core→@stainless-code/layers):LayerClient(ensureStack,getStackIds,subscribeStacks),LayerStack(dismiss/dismissAll/cancelAll/addBlocker/settle/setRunning/cancelQueued/getQueuedSnapshot),Layer,Subscribable,notifyManager,ControlledPromise,createCallContext,layerOptions,layerKey/DataTag,createLayer/LayerHandle/ValidatedLayerHandle,createLayerGroup/childStackId,layerGcCache(internal),errors, payload validation (StandardSchemaV1,Validator,PayloadValidationError,isPayloadValidationError),Register/DefaultLayerError,utils. Zero deps;sideEffects: false. Entry.→src/index.ts. - Adapters (
packages/<fw>→@stainless-code/<fw>-layers): each declares the library or framework as a required peer dependency and@stainless-code/layersas a direct dependency, re-exports core (includingcreateLayer), and exposes the wired/observe hooks (useLayer/useLayerState/useQueuedStack/useLayerQueuedState— names vary per § Adapter ergonomics), auseStack-shaped binding, an idiomatic client-context provider +useLayerClient(React/PreactStackProvider; SveltesetLayerClient; Vue/LitprovideLayerClient; SolidLayerClientContext; AngularLAYER_CLIENT+provideLayerClient; AlpinegetLayerClient/setLayerClientin plugin closure), and a rendering surface (StackOutlet, or the imperative/primitive equivalent — see § Adapter ergonomics). Lit peers arelit+@lit/context; CEs register via explicitdefineStackElements()(not on import). Alpine peersalpinejs(required) plus optional peer@alpinejs/focus(peerDependenciesMeta.optional);./cdnbootstrap mirrors ESM. Svelte ships two entries in one package:.(runes,svelte/reactivity, 5.7+) and./store(stores,svelte/store, 3.0+). All adapters have reached ergonomic parity; Angular, Alpine, and Svelte diverge by design (compiler-free / markup-in-template → primitive rendering rather than shipped host components or acomponentregistry); Lit diverges with shadow provider + light-DOM outlet CEs +LayerGroup.outlet()(footnote ⁷).
Cross-package type resolution in dev uses a @stainless-code/source export condition (each package's exports maps it to src) plus tsconfig customConditions, so typecheck resolves core's source without a build-order dependency; published consumers get dist via the standard types/import conditions.
The isolation invariant: each adapter package imports only @stainless-code/layers + its library or framework peer. The package boundary enforces it structurally (an undeclared import fails install/build); sherif (workspace dependency lint) + per-package knip catch drift, and core keeps a zero-dependency guard. No cross-adapter coupling.
Optional Devtools packages (layers-devtools, react-layers-devtools) sit outside this seam — TanStack UI, not zero-dep core and not framework adapters. Core owns StackNotifyEvent + subscribeNotify / seedNotify (no @tanstack/*); EventClient lives only in layers-devtools. Notify emits when #flush changes snapshot/queued refs (plus register) — not on every labeled #dispatch. Rejected for v1: EventClient in core; bus-bidirectional panel commands (live LayerClient actions instead).
Every adapter exposes the same primitives (client context, useLayerClient, wired + observe hooks, useStack/useQueuedStack options-bag subscriptions) and re-exports the full core, so anything one adapter can do is reachable in another through those. On top of the primitives each adapter ships ergonomic wrappers — sugar over the primitives, not extra engine capability. All adapters have reached parity; Angular, Alpine, and Svelte diverge by design because they build compiler-free (tsdown, no framework compiler), so they render through markup-in-template / primitive APIs instead of shipped host components or a component registry.
Drive vs observe: useLayer / createLayer drive; useLayerState / useLayerQueuedState / useStack / useQueuedStack observe only. Stack-level ops route via handle.stack.* (not re-delegated onto the handle). Terms: glossary.
Array observe + topmost find: observe hooks return LayerState[] (all same-key). LayerStack.find is topmost (findLast); cancelQueued without { id } is first-queued (FIFO), with { id } the exact queued instance — different collections, different defaults.
| Capability | React | Preact | Vue | Solid | Angular | Lit | Alpine | Svelte (runes) | Svelte (store) |
|---|---|---|---|---|---|---|---|---|---|
| Primitives + core re-export | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
StackOutlet (built-in host) |
✅ | ✅ | ✅ | ✅ | ✅¹ | ✅ | ✅⁸ | —⁴ | —⁴ |
StackSubscribe (render-prop / scoped slot) |
✅ | ✅ | ✅ | ✅ | —² | ✅ | —⁸ | —⁴ | —⁴ |
useStackHandles (headless { states, getCall }) |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ | —⁸ | —⁵ | —⁵ |
useMutationFlow (async-action helper) |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
useLayerGroup (nested-stack hook) |
✅ | ✅ | ✅ | ✅ | ✅³ | ✅ | ✅⁸ | ✅⁶ | ✅⁶ |
createStackHook (useAppStack/AppHost/AppLayer) |
✅ | ✅ | ✅ | ✅ | ✅³ | ✅ | ✅⁸ | —⁴ | —⁴ |
¹ Angular ships renderStack(vcr) — a compiler-free imperative outlet (ViewContainerRef.createComponent + setInput, id-keyed so state changes update inputs without recreating components). ² Angular has no StackSubscribe; its useStack({ select }) signal is the idiomatic equivalent. ³ Angular's useLayerGroup/createStackHook expose renderInto(vcr) instead of an Outlet component; createStackHook returns { provideClient, useAppStack, renderInto } (no AppHost/AppLayer). ⁴ Svelte ships no .svelte components: render with {#each useStack().current as s} + callFor(s) (runes) / {#each $stack} + callFor(client, stackId, s) (store) in your own markup. ⁵ Svelte's useStack() already returns the headless pair — .current (states) + callFor. ⁶ Svelte's useLayerGroup exposes the child stack (SvelteStack / Readable) instead of an Outlet. ⁷ Lit's useLayerGroup exposes outlet(): TemplateResult (router Routes.outlet()-shaped) instead of an Outlet component; StackProvider uses default shadow + <slot>; StackOutlet/StackSubscribe/app-host are light-DOM CEs. Register via explicit defineStackElements() (not on import; "sideEffects": false). Layer component is a LitElement constructor or (props) => TemplateResult — no tag strings. No ./signals until Lit first-party signals (Svelte dual-API bar); @lit/context ContextRoot stays optional for late-upgraded providers (not auto-installed). ⁸ Alpine: x-layer-outlet + $layer (outlet expression evaluates to a stack id string — quote in markup); Rank-2 Alpine.data('layerStack') + x-for + callFor; no component on layerOptions; createStackHook → { setClient, useAppStack } (no AppHost/AppLayer); client via getLayerClient / setLayerClient (not Alpine.store); CDN ./cdn before Alpine core (alpine:init).
Per-adapter API detail lives on the Blume adapters pages; this matrix is the cross-adapter comparison for choosing and porting between adapters.
pending ──loadFn──▶ active ──dismiss──▶ dismissed + exiting ──exitingDelay/#remove──▶ removed ──gcTime──▶ cached
│ │
│ loadFn throws │ dismissAll
▼ ▼
error (all active + queued resolved)
serial scope: later opens wait as `queued` (not mounted) — see `getQueuedSnapshot()`
pending—loadFnin flight (cancelable viaAbortController).queued— serial scope only: waiting behind the occupying layer; not ingetSnapshot(); visible viagetQueuedSnapshot().active— mounted; component receivescall(end/dismiss/update/setRunning/settle/ended/index/stackSize/root/stackId/layerId/addBlocker),payload,data,error,phase,transition,actionStatus,dismissing.dismissed— caller'sawaitresolved (ended=true);transition: "exiting"keeps the layer mounted forexitingDelay(or untilsettle); on removal, cached forgcTimeso re-opening the same key restoresdatawithout re-runningloadFn.error—loadFnthrew; the caller'sawaitrejects.
Three independent axes: phase (resolution), transition (animation — see § Transitions), and actionStatus (idle | running) for in-layer mutations.
Animation is a third axis orthogonal to phase, mirroring the actionStatus precedent:
phase: "pending" | "queued" | "active" | "dismissed" | "error"; // resolution
transition: "entering" | "settled" | "exiting"; // animation
actionStatus: "idle" | "running"; // in-flight action"exiting" is not a phase member; enter/exit animation lives on transition. phase: "dismissed" means the promise resolved; transition disambiguates exit anim vs cached:
| moment | phase |
transition |
|---|---|---|
| opening, loading | pending |
entering |
| opening, no load | active |
entering |
| open & idle | active |
settled |
| dismissed, animating out | dismissed |
exiting |
dismissed, cached (gcTime) |
dismissed |
settled |
| load threw | error |
settled |
Completion = whichever fires first of { delay elapsed, call.settle() }:
enteringDelay— ms beforeentering → settled(default0= instant).exitingDelay— ms before removal after dismiss (default0= instant).call.settle()— imperative early finish:entering → settled(clears enter timer;phaseuntouched);exiting → remove(clears exit timer); no-op if alreadysettled.
Delay 0 (default) flips the axis synchronously — no transition frame observed, backward compatible. Fixed CSS transitions: set enteringDelay/exitingDelay, skip settle(). Springs/variable duration: generous cap delay + onTransitionEnd/onRest → call.settle().
Layers are client-only today (React/Preact getServerSnapshot returns []); when SSR hydration lands, initial transition must be "settled".
Identity: each layer has a unique instance id (LayerState.id, `${hashKey(key)}#n`) for rendering keys and instance lookup/removal. The key is the logical identity (find/upsert/gcTime use keySignature(key)); keys must be JSON-safe or hashKey throws LayerKeyError. Multiple live layers may share a key in a parallel stack.
Consumer predicates gate user-intent dismissal — end/dismiss, backdrop/Esc, programmatic dismiss/dismissAll — while never stranding teardown. Invariant: a blocker can delay a user closing a layer, never prevent the system from tearing it down.
Registration — two scopes, one gate (any veto → blocked); each returns a disposer; multiple per scope (Set):
call.addBlocker(fn: () => boolean | Promise<boolean>): () => void; // instance
stack.addBlocker(fn: (layer: LayerState) => boolean | Promise<boolean>): () => void; // stack policytrue = allow; falsy = veto. Instance scope matches the instance-id model (dirty state lives on call). Stack scope covers app policy (selective by layer).
Async + reject — predicates may be async; dismiss awaits them. A veto rejects the attempt (layer stays open; caller re-issues after confirming) rather than deferring the caller's Promise<R>. Confirm UI is the consumer's own layer — core never opens UI.
call.end(response, opts?: { force?: boolean }): Promise<boolean>; // was void
call.dismiss(response, opts?: { force?: boolean }): Promise<boolean>;Return value = "did it dismiss?". { force: true } bypasses blockers. Repeat end/dismiss while dismissing dedupes to the in-flight promise; force wins immediately. Predicate throw/reject = veto (fail-closed; dev warning).
Paths — honor blockers: end/dismiss (user intent). Skip: cancelQueued (serial, never mounted), cancelAll (system teardown). Layer-group cascade (onLayerDismiss → #drainChildStacks → cancelAll) rejects child open() with LayerCancelledError — guard the parent instead.
dismissAll modes — stack.dismissAll(response, opts?: { mode? }) is async:
| mode | behavior |
|---|---|
"skipBlocked" (default) |
attempt each; close permitted, leave blocked open |
"stopAtBlocked" |
process in order; halt at first blocked |
"force" |
bypass all blockers |
Default configurable via StackOptions.dismissAllMode / LayerClientOptions.defaultStackOptions. Precedence: per-call mode > stack default > "skipBlocked".
dismissing — LayerState.dismissing (true while blockers are evaluated for a user-intent dismiss); back to false on veto or removal. Generic UI can disable the close button during async confirm. Veto is transient (via Promise<boolean>), not a persisted flag.
Gate runs before exit transition (§ Transitions): allowed → resolve promise, phase: "dismissed", transition: "exiting"; vetoed → layer stays active.
open returns Promise<R> (the await-the-response contract) — so error types are not carried on the promise (TypeScript can't type promise rejections). The promise rejects with:
- the
loadFn-thrown error (typedEat runtime), - a
PayloadValidationErrorwhenvalidatefails (see below), or - a
LayerCancelledErrorfrom system teardown (cancelAll, parent-dismiss child clear, group dispose, host disconnect).
Narrow in a catch with the shipped guards (isPayloadValidationError, isLayerCancelledError) or the app's own error guards. A typed-error-on-await would require a Result-returning open, which we deliberately reject to keep the direct-await ergonomic; an opt-in openSafe() may come later.
Payload validation — an optional validate (a Standard Schema or a sync (input) => output fn) on open/layerOptions parses untrusted input synchronously at open, before mount. It parses/transforms: open's payload argument is the schema input, while the layer stores (and the component sees) the output. Invalid input rejects open with PayloadValidationError and mounts nothing; an async schema is a config error. No schema-library dependency — Standard Schema is the universal interface.
- Named stacks —
LayerClient.getStack(id); isolated.open({ stack })picks one. scope: { strategy: 'serial', onLoadError?: 'block' | 'advance' }— one occupying layer at a time (pending/active/errorunder defaultonLoadError: 'block'); later opens queue (phase: 'queued', visible viagetQueuedSnapshot()) and activate when the occupant leaves.onLoadError: 'advance'removes a failedloadFnlayer and drains the next queued open.parallel(default) stacks freely.dismissAll(async; see § Blockers) drains the queue, resolving queued callers without mounting.gcTime— dismissed layers cached (in the internallayerGcCachemodule — a small removable-cache primitive) so re-opening the same key skipsloadFn. One slot per key signature — last-dismissed-wins; the displaced entry is evicted with explicit teardown, and re-open cancels the timer. A cached layer is off-getSnapshot()and inherently unobserved, so the timer-runs-while-cached model already matches observer-gated gc. Rejected: pausing gc while the stack is unmounted (would serve unbounded-stale data — nostaleTime/refetch model — and risk retention), and a per-layer observer refcount (a stack-level proxy for it); revisit only alongside a real freshness model.
Subscribable wraps each listener in notifyManager.batchCalls at subscribe time; open/dismiss/update run inside notifyManager.batch, so multiple mutations in one tick collapse to one UI re-render. The flush is synchronous (predictable, UI-agnostic, no per-adapter scheduler). To coalesce several independent calls in one tick into a single notification across every adapter, wrap them yourself: notifyManager.batch(() => { client.open(a); client.open(b); }).
useStack({ stack?, select?, compare? }, client?) → useSyncExternalStore(stack.subscribe, getSnapshot, getServerSnapshot). Same options-bag shape for useQueuedStack, useLayerState, useLayerQueuedState. The snapshot is cached on LayerStack (stable ref between mutations); the hook memoizes selector output against that base snapshot with compare-equality (default Object.is) so object/array selectors don't churn. StackOutlet maps the snapshot to components, building call via createCallContext. SSR-safe getServerSnapshot returns [].
- React / Preact —
useSyncExternalStorecallsgetServerSnapshotand never invokessubscribeon the server, so the store is read once with no listener registered. This is the whole SSR-safety story for these adapters. - Svelte —
createSubscriberis lazy: it only starts the subscription when.currentis read inside a reactive (effect) context, which doesn't happen during server string rendering — so no eager server subscription. - Vue / Solid / Angular — subscribe eagerly but register teardown (
onScopeDispose/fromowner /effectonCleanup) that runs when the library or framework disposes the SSR render scope, so no cross-request listener leak. - Lit — Reactive Controllers subscribe in
hostConnectedand tear down inhostDisconnected;hostConnecteddoes not fire during SSR string rendering, so no listener is registered server-side. - A proactive "skip subscribe on the server" guard for Vue/Solid/Angular was deliberately not added: there is no clean, test-safe server detector (bun's test env has no
windowand resolves Solid to its SSR build, so atypeof window/isServerguard would skip subscription in the adapters' own unit tests and break their reactivity assertions), and the teardown above already prevents leaks. Revisit only if a concrete SSR leak surfaces.
useStackHandles(stack?, rootProps?) → { states, getCall } is the headless primitive for rendering a stack's layers yourself (custom per-layer components, portals, switches). Angular and Svelte expose equivalent rendering paths by design — see § Adapter ergonomics. Where shipped, StackOutlet is the registered-component convenience built on useStackHandles, dev-warning when a layer has no component. StackSubscribe is a render-prop subscription with selector isolation. StackOutlet deliberately has no render prop — use useStackHandles or StackSubscribe.
A parent layer owns a child stack on the same LayerClient via createLayerGroup(client, call) (each adapter wraps it as useLayerGroup(call)). The child stack id is derived collision-safely from the parent's stackId + instance layerId — `${parentStackId}~${parentLayerId}~${name}` — so sibling and nested groups never clash. Lifetime is event-driven: LayerStack.onLayerDismiss fires on dismiss → the client cancelAlls that layer's registered child stacks (reason: "parentDismiss"), recursively for nesting. No second client, no manual cleanup. Rejected alternatives: a second LayerClient (disconnected from provider/config) and a call.group() method (would couple the call-context module to the client).
layerOptionsbrands itskeywith aDataTag, soLayerClient.openinfers the response type with no explicit generic;openalso has a validated overload (see § Error handling) wherepayloadis the schema input.Register(module augmentation) setsDefaultLayerErrorapp-wide (Errorunless augmented). There is no global key→response registry — colocatedlayerOptions/layerKeyis the single source of truth.- Each
opencreates aLayerwith a unique instance id (`${hashKey(key)}#n`); thekeyis the logical identity used byfind/upsert/gcTime, so aparallelstack may hold multiple same-key layers without collisions. openreturnsPromise<R>(the await-the-response contract) — rejections are not typed (a TS limitation; aResultwrapper was rejected to preserve directawait); narrow errors via branded guards likeisPayloadValidationError.
Each package owns its tests (bun run --filter '*' test fans out).
| Suite | Runner | Scope |
|---|---|---|
packages/*/src/**/*.test.ts |
bun:test |
core engine (packages/core) + each adapter binding (library or framework module mocked or scoped) |
packages/*/tests-dom/** |
vitest + jsdom + per-adapter harness |
real-renderer DOM suites per adapter — rendering, mutation-flow, layer-group, no-remount invariant |
packages/*/**/*.test-d.{ts,tsx} |
tsc --noEmit (per-package typecheck) |
type-level inference — core plus every adapter |
Adapter binding tests mock the library or framework module (solid-js → client build; @angular/core stub; preact/compat stub) or use a real reactive scope (vue effectScope), and assert the value contract. The reactive auto-update path that needs a full renderer is pinned per adapter by the DOM suites; the LayerStack subscription it rides on is pinned in core tests. Package-level import isolation (each adapter only depends on core + its library or framework peer) is enforced by the package boundary + sherif/knip rather than a per-file test.
DOM-harness convention: each DOM-tested package owns a vitest.config.ts + its framework's testing library + jsdom/vitest devDeps (React/Preact/Vue @testing-library/<fw>; Solid vite-plugin-solid + @solidjs/testing-library; Angular @analogjs/vite-plugin-angular + TestBed with a self-contained tsconfig.spec.json; Lit vitest + jsdom with real custom elements; Alpine vitest + jsdom with real alpinejs; Svelte @sveltejs/vite-plugin-svelte + @testing-library/svelte), a test:dom script, and its tests-dom/** registered as a knip entry. Test deps are version-pinned to match React's so sherif stays green. DOM tests resolve the package by name → its built dist, so build before test:dom (the root check builds first).