Open any layer from anywhere in Angular and manage modal, dialog, drawer, popover, and toast UI as ordered, named stacks. @stainless-code/angular-layers adds Angular signals, DI, and imperative rendering helpers to @stainless-code/layers; awaiting a typed result and fire-and-forget invocation (void client.open(...)) are equally first-class.
Named stacks, singletons via upsert with live update, serial queues, nested child stacks, transitions, dismissal blockers, and validation provide standalone value. Full fit matrix: README — When to use it.
bun add @stainless-code/angular-layers@stainless-code/layers core is pulled in automatically and re-exported. @angular/core is a required peer (>=17.0.0, signals).
Register an Angular component on the layer. Layer components receive @Input() fields matching LayerComponentProps (call, payload, phase, actionStatus, …).
import { Component, Input } from "@angular/core";
import {
layerOptions,
type LayerComponentProps,
} from "@stainless-code/angular-layers";
export type ConfirmPayload = {
title: string;
message: string;
};
export type ConfirmResponse = boolean;
@Component({
selector: "app-confirm-dialog",
standalone: true,
template: `
<div role="dialog">
<h2>{{ payload.title }}</h2>
<p>{{ payload.message }}</p>
<button type="button" (click)="call.end(true)">Yes</button>
<button type="button" (click)="call.end(false)">No</button>
</div>
`,
})
export class ConfirmDialogComponent {
@Input({ required: true })
call!: LayerComponentProps<ConfirmPayload, ConfirmResponse>["call"];
@Input({ required: true })
payload!: ConfirmPayload;
}
export const confirm = layerOptions<ConfirmPayload, ConfirmResponse>({
stack: "confirm",
key: ["confirm", "remove"],
component: ConfirmDialogComponent,
exitingDelay: 200,
});import type { ApplicationConfig } from "@angular/core";
import { provideLayerClient } from "@stainless-code/angular-layers";
export const appConfig: ApplicationConfig = {
providers: [provideLayerClient()],
};Call renderStack in an injection context (e.g. a host component constructor). It imperatively renders each active layer's registered component into a ViewContainerRef via createComponent + setInput. Components are keyed by stable layer id — state changes update inputs without recreating the ComponentRef.
import { Component, ViewContainerRef } from "@angular/core";
import { renderStack } from "@stainless-code/angular-layers";
@Component({
selector: "app-confirm-host",
standalone: true,
template: "",
})
export class ConfirmHostComponent {
constructor(vcr: ViewContainerRef) {
renderStack(vcr, "confirm");
}
}Place <app-confirm-host /> once near the root (or call renderStack against any ViewContainerRef outlet — including one from @ViewChild). Outside a constructor, wrap the call in runInInjectionContext(injector, () => renderStack(vcr, stack)).
For fully custom hosts, use useStackHandles(stack?, rootProps?) → { states, getCall } and iterate the states signal yourself.
import { Component } from "@angular/core";
import { useLayerClient } from "@stainless-code/angular-layers";
import { confirm, type ConfirmResponse } from "./confirm.layer";
@Component({
selector: "app-remove-button",
standalone: true,
template: `<button type="button" (click)="remove()">Remove</button>`,
})
export class RemoveButtonComponent {
private readonly client = useLayerClient();
async remove() {
const ok: ConfirmResponse = await this.client.open({
...confirm,
payload: { title: "Remove?", message: "Sure?" },
});
// ok: boolean
}
}ConfirmResponse is inferred from layerOptions<ConfirmPayload, ConfirmResponse> — no explicit generics on open.
When P is void, unknown, undefined, or a union containing undefined, omit the payload key; R defaults to void. This no-payload toast is opened fire-and-forget and dismissed without a response:
import { Component, Input } from "@angular/core";
import {
layerOptions,
type LayerComponentProps,
useLayerClient,
} from "@stainless-code/angular-layers";
@Component({
selector: "app-saved-toast",
standalone: true,
template: `
<div role="status">
Saved
<button type="button" (click)="call.dismiss()">Dismiss</button>
</div>
`,
})
export class SavedToastComponent {
@Input({ required: true }) call!: LayerComponentProps["call"];
}
export const savedToast = layerOptions<void>({
stack: "toast",
key: ["toast", "saved"],
component: SavedToastComponent,
});
@Component({
selector: "app-save-button",
standalone: true,
template: `<button type="button" (click)="showSaved()">Save</button>`,
})
export class SaveButtonComponent {
private readonly client = useLayerClient();
showSaved() {
void this.client.open(savedToast);
}
}Mount the toast stack the same way — renderStack(vcr, "toast") in a host component.
The package builds compiler-free with tsdown, so no adapter-shipped @Component outlets (StackOutlet, Outlet, AppHost, AppLayer, StackProvider). Rendering is imperative:
| React | Angular |
|---|---|
<StackProvider> |
provideLayerClient() in app config |
<StackOutlet stack="…" /> |
renderStack(vcr, stack) or renderInto(vcr) from useLayerGroup / createStackHook |
<StackSubscribe selector={…}> |
useStack(stackId, selector) — a Signal, not a component |
createStackHook → { StackProvider, AppHost, AppLayer } |
createStackHook → { provideClient, useAppStack, renderInto } |
Use useStack(stack, selector) for isolated subscriptions; there is no StackSubscribe render-prop component.
All imports from @stainless-code/angular-layers.
| Export | Signature | Role |
|---|---|---|
LAYER_CLIENT |
InjectionToken<LayerClient> |
DI token for the app client |
provideLayerClient |
(client?: LayerClient) => FactoryProvider |
Add to providers; creates a client when omitted |
useLayerClient |
() => LayerClient |
inject(LAYER_CLIENT) — injection context only |
useStack(stackId?, selector?, compare?) => Signal<T>— defaultTisLayerState[];comparedefaults toObject.is. Explicit-client overload:(client, stackId?, selector?, compare?).useLayer(key, stackId?, compare?) => Signal<LayerState | null>— one layer by key;nullwhen inactive.DataTagkeys inferRandE. Explicit-client overload available.
useStack and useLayer return read-only signals and must run in an injection context (they use effect() internally).
readonly count = useStack("confirm", (states) => states.length);
readonly top = useStack("confirm", (states) => states.at(-1) ?? null);renderStack(vcr, stack?, rootProps?) => void— imperative outlet; renders registeredcomponenttypes intovcr. Injection context only.useStackHandles(stack?, rootProps?) => StackHandles— headless{ states: Signal<LayerState[]>, getCall }for custom@forhosts.
useLayerGroup(call, options?) => LayerGroup— child stack scoped to a parent layer's lifetime. Returns{ open, dismissAll, states, stackId, renderInto }. CallrenderInto(vcr)instead of mounting an outlet component.useMutationFlow(call) => MutationFlow<R>—{ pending: Signal<boolean>, run }.run(fn).orEnd(response)ends on success; leaves the layer open and rethrows on failure. Useflow.pending()in templates:
<button [disabled]="flow.pending()" (click)="save()">Confirm</button>Call useMutationFlow in an injection context (e.g. the layer component constructor).
| Export | Signature | Role |
|---|---|---|
createStackHook |
({ stack?, client? }) => StackHook |
Bind stack id once. Returns { provideClient, useAppStack, renderInto }. |
useAppStack()—{ open, dismissAll, states }withstackpre-bound.renderInto(vcr, rootProps?)— imperative outlet for the bound stack.
No StackProvider, AppHost, or AppLayer components — register provideClient() in config and call renderInto(vcr) from a host.
StackHandles, MutationRun<R>, MutationFlow<R>, ScopedOpen, LayerGroup, AppStack, StackHook.
export * from "@stainless-code/layers" — import core APIs from the same path: LayerClient, layerOptions, layerKey, createCallContext, createLayerGroup, Layer, LayerStack, LayerComponentProps, PayloadValidationError, isPayloadValidationError, types (LayerState, LayerCallContext, DataTag, …), and the rest of the zero-dep engine.
Engine concepts (transitions, blockers, validation, serial scope, gcTime) live in core — see the layers skill and architecture.
Full guide: repo README.