Overview
Overlay state is cross-cutting; a zero-dep headless engine plus framework adapters split it from UI rendering.
A confirm, toast, or drawer rarely stays where it started — prop-drilled isOpen and threaded callbacks don’t scale. Layers moves that into a headless client: declare once, mount an outlet, await confirm.open(...) from any call site (wired handle or bag-form). Zero-dep core; thin adapters bind each framework’s reactivity.
Engine model
LayerClient ──┬── LayerStack (named, ordered) ── Layer[]
└── LayerClientOptions.defaultStackOptions
- Declare —
layerOptions<P, R>({ stack, key, ... })brands the keyThe logical identity of a layer; find/upsert/gcTime operate on its signature. with aDataTagsoopen()infers the response type. - Observe — subscribe to a stack snapshot; adapters bind
LayerStack.subscribe/getSnapshotto UI reactivity. - Call — a wired handle’s
open(payload)(or bag-formclient.open({ ...options, payload })); resolution happens when something callscall.end/call.dismiss(response optional whenundefined extends R).
Package boundary
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.
Core (@stainless-code/layers)
Zero runtime dependencies; sideEffects: false.
Engine — LayerClient (ensureStack, getStackIds, subscribeStacks) owns app-wide stacks; LayerStack (dismiss, dismissAll, settle, setRunning, cancelQueued, getQueuedSnapshot) is one named, ordered stack; Layer is a single instance in that stack.
Declare & infer — layerOptions, layerKey / DataTag brand keys so open() infers payload and response types without explicit generics.
Lifecycle — phase, transition, actionStatus are three independent axes; createCallContext is the per-layer handle (end, dismiss, settle, setRunning, addBlocker).
Stacks & scope — named stacks, serial/parallel scope, upsert, gcTime; createLayerGroup / childStackId for nested child stacks.
BlockersA consumer predicate that gates dismissal; true allows, falsy or a thrown error vetoes. — instance- and stack-scoped addBlocker gate user-intent dismissal; dismissAll supports skipBlocked / stopAtBlocked / force modes.
Types & validation — Register / DefaultLayerError for app-wide error typing; payload validation via StandardSchemaV1, Validator, PayloadValidationError, isPayloadValidationError (payload validation).
Internals — Subscribable, notifyManager (batched mutations → one re-render); ControlledPromise for awaitable open resolution; hashKey, keySignature utilities.
Adapters (@stainless-code/<fw>-layers)
Each adapter peers its framework, depends on @stainless-code/layers (re-exported), and ships client context + useStack / wired-handle hooks plus a render surface (StackOutlet or the imperative/primitive equivalent — convenience, not required). Install one adapter package; core comes transitively. Svelte: . (runes, 5.7+) and ./store (stores, 3+).
Subscription model
Adapters subscribe to LayerStack.getSnapshot() and project via a selector. On React and Preact, useStack({ stack, select, compare }) wraps useSyncExternalStore(stack.subscribe, getSnapshot, getServerSnapshot). The snapshot is cached on LayerStack (stable ref between mutations); the hook memoizes selector output with compare-equality (default Object.is) so object/array selectors don’t churn.