Primitives
Dialog
A modal window overlaid on the page, with a focus trap, scroll lock and Escape / dismiss handling. Also openable imperatively through ForDialogManager.
Open the dialog and press Tab: focus is trapped inside it, Escape closes it, and focus returns to the trigger that opened it.
New to overlays in forty-cdk? Your first overlay walks a Popover from empty markup to styled-and-animated and explains the
@if/ open-state model and the portal → global CSS rule.
When to choose
All three modal-family surfaces float above the page; what separates them is what they do to focus and to everything behind them.
- Dialog: modal. Focus is trapped inside the surface, the background is inert and body scroll is locked, so the task has to be finished or dismissed before anything else is reachable.
- Drawer: the same modal engine anchored to an edge, plus a pointer drag with swipe-to-dismiss and snap points. Choose it when the surface slides in from a side and the user may drag it.
- Popover: non-modal. Focus moves in and returns to the trigger on close, but Tab is free to leave and the page behind stays interactive. Choose it when the user should be able to keep working around the surface.
Two flows, one engine
The same focus trap, scroll lock, portal, and dismissible-layer behaviors run under both APIs. Pick the one that fits the call site. Declaratively, [forDialog] is an overlay where mount equals open: the consumer's signal drives @if, and the directive emits (dismiss) when it wants to be unmounted (Escape, backdrop, outside-pointer, outside-focus, close button). There is no [(open)] two-way binding on [forDialog]: the directive never opens itself, only requests close. Imperatively, ForDialogManager.open() creates the surface for you and hands back a ForDialogRef (see Programmatic API).
For the open side, drop [forDialogTrigger] on a <button>. It two-way binds [(open)] to the same signal that gates the surrounding @if, and wires aria-haspopup="dialog", aria-expanded, aria-controls, and data-state automatically.
import { Component, signal } from '@angular/core';
import { ForDialog, ForDialogClose, ForDialogTitle, ForDialogTrigger } from 'forty-cdk/dialog';
@Component({
imports: [ForDialog, ForDialogTrigger, ForDialogTitle, ForDialogClose],
template: `
<button forDialogTrigger [(open)]="open" controls="confirm-delete">Delete account</button>
@if (open()) {
<div forDialog id="confirm-delete" (dismiss)="open.set(false)" animate.leave="fade-out">
<h2 forDialogTitle>Delete account?</h2>
<button forDialogClose>Cancel</button>
</div>
}
`,
})
export class DemoConfirm {
readonly open = signal(false);
}
Wrapping with @if is what makes Angular's native animate.enter / animate.leave work, because they fire on real mount / unmount, not on attribute toggling.
Anatomy
<!-- trigger: controls="<id>" tells it which dialog it opens -->
<button forDialogTrigger [(open)]="open" controls="my-dialog">Open</button>
<!-- surface: id="<id>" must match controls above -->
<!-- rendered only while open() is true, so animate.enter / animate.leave fire on real mount -->
<div forDialog id="my-dialog" (dismiss)="open.set(false)" animate.leave="fade-out">
<div forDialogBackdrop class="my-backdrop"></div>
<h2 forDialogTitle>Delete account?</h2>
<p forDialogDescription>This action is permanent.</p>
<button forDialogClose>Cancel</button>
<button (click)="confirm()">Delete</button>
</div>
The trigger ([forDialogTrigger]) and the dialog surface ([forDialog]) are separate, unrelated elements. They wire to each other via a shared id that the consumer keeps in sync: [forDialogTrigger] always reflects aria-haspopup="dialog" and aria-expanded ("true" / "false", from the trigger's own open state), and neither depends on controls. The controls value is what gets reflected as aria-controls="my-dialog", and only while the dialog is open; omit controls and the trigger never gets an aria-controls, silently breaking assistive technology that announces "opens dialog X". Popover is different: [forPopover] wraps both the trigger and content in a single parent directive, so ids are auto-generated and kept in sync internally. Dialog is flat (trigger and surface can live anywhere in the template), so the wiring is manual.
Examples
Guarded close
(escapeKeyDown) and (interactOutside) fire before (dismiss); calling preventDefault() on them keeps the dialog open. Escape and click-outside are vetoed, so only Discard or Save close it. Modal with no backdrop, so the page behind is inert but undimmed.
Non-modal & keep focus
[modal]="false" drops the focus trap, scroll lock and inert siblings. autoFocusOnOpen vetoes the initial focus move so the search field keeps focus while you type, and autoFocusOnClose returns focus to it on close. With no visible title, ariaLabel names the panel.
Type to open the results — focus never leaves this field.
Programmatic (ForDialogManager)
Open a component imperatively and await its result. The manager mounts it under the same [forDialog] engine, so [forDialogClose] [closeWith] propagates straight to ForDialogRef.close(value). Here as a non-dismissible alertdialog; class / animateLeave / backdropAnimateLeave style and fade out the manager-created host.
API
ForDialog
(dismiss) is the main signal. Wire it to flip the @if gate. The four dismiss outputs are vetoable: each receives a VetoableNativeEvent<E> carrying the underlying DOM event. Call preventDefault() on the emitted veto to suppress the directive's default action; the original DOM event is on .event.
| Property | Type | Description |
|---|---|---|
dismissible | — | When false, Escape, backdrop, outside-pointer, and outside-focus do not request close. The close button still does.Default: true |
modal | — | When false, no aria-modal, no scroll lock, no focus trap.Default: true |
alert | — | Switches role to alertdialog.Default: false |
returnFocus | — | Focus returns to the previously focused element on close. Default: true |
initialFocus | — | 'first' (first focusable inside) or 'container' (the dialog host).Default: 'first' |
ariaLabel | — | Manual aria-label if no [forDialogTitle] is rendered.Default: null |
dismiss | OutputEmitterRef | Output. Dialog wants to be unmounted. Reasons: 'escape', 'backdrop', 'pointerDownOutside', 'focusOutside', 'closeButton', 'programmatic'. Spelled dismiss rather than close on purpose (see Behavior notes).Default: — |
escapeKeyDown | OutputEmitterRef | Output. Escape while this dialog is the topmost dismissible layer. Default: — |
pointerDownOutside | OutputEmitterRef | Output. Pointer-down outside the dialog. Default: — |
focusOutside | OutputEmitterRef | Output. Focus moves outside the dialog. Default: — |
interactOutside | OutputEmitterRef | Output. Composite: fires alongside both of the above (and shares their veto state). Default: — |
| Data attribute | Values | |
|---|---|---|
data-state | open (always: the host is only mounted while open, so it is never closed) | |
data-depth | 0 for the first mounted dialog, one above the deepest dialog still mounted otherwise. Fixed for the dialog's lifetime |
Inputs — focus callbacks
The auto-focus pair is bound as function references (input callbacks), not as event listeners. Each callback receives a VetoableEvent whose preventDefault() suppresses the directive's default focus action.
| Property | Type | Description |
|---|---|---|
autoFocusOnOpen | (event: VetoableEvent) => void | Just before focus moves into the dialog on mount. Call event.preventDefault() to skip the imperative initial focus.Default: — |
autoFocusOnClose | (event: VetoableEvent) => void | Just before focus returns to the trigger on unmount. Fires on every close path regardless of mode; in non-modal mode the directive doesn't move focus, so the veto is informational. Call event.preventDefault() to skip the modal return-focus.Default: — |
ForDialogTrigger
| Data attribute | Values | |
|---|---|---|
data-state | open | closed | |
data-disabled | present / absent |
ForDialogBackdrop
| Data attribute | Values | |
|---|---|---|
data-state | open (always, since it is mounted alongside the dialog) | |
data-for-dialog-backdrop | present (stable marker; portaled alongside the dialog, so use it to select the backdrop) | |
data-depth | its dialog's data-depth |
ForDialogClose
| Data attribute | Values | |
|---|---|---|
data-state | open (always, since it is mounted alongside the dialog) |
ForDialogInitialFocus
[forDialogInitialFocus] marks the element that receives focus when the enclosing dialog opens, in place of the one initialFocus picks. It takes no inputs, and it works inside a component opened with ForDialogManager.open() as well, since the marker lives in the content rather than in the caller. When the marked element is missing, disabled or hidden at mount, focus falls back to initialFocus; a vetoed autoFocusOnOpen still skips the move. Mark one element per dialog: a second marker warns in dev mode and only the newest is used.
<div forDialog (dismiss)="open.set(false)">
<h2 forDialogTitle>Delete view</h2>
<button forDialogClose aria-label="Close">×</button>
<p>This cannot be undone.</p>
<button forDialogClose forDialogInitialFocus>Cancel</button>
<button (click)="deleteView()">Delete</button>
</div>
Programmatic API
import { Component, inject } from '@angular/core';
import { ForDialogManager, ForDialogRef, injectDialogData } from 'forty-cdk/dialog';
@Component({
template: `
<p>{{ data?.message }}</p>
<button (click)="ref.close('cancel')">Cancel</button>
<button (click)="ref.close('confirm')">Confirm</button>
`,
})
class ConfirmDialog {
readonly data = injectDialogData<{ message: string }>();
readonly ref = inject(ForDialogRef) as ForDialogRef<'confirm' | 'cancel'>;
}
@Component({
selector: 'demo-host',
template: `<button (click)="askToDelete()">Delete</button>`,
})
export class DemoHost {
readonly dialogs = inject(ForDialogManager);
async askToDelete() {
const ref = this.dialogs.open<ConfirmDialog, 'confirm' | 'cancel', { message: string }>(
ConfirmDialog,
{ data: { message: 'Are you sure?' } },
);
const { result } = await ref.closed; // result: 'confirm' | 'cancel' | undefined
if (result === 'confirm') {
/* ... */
}
}
}
injectDialogData<T>() is typed T | null: the manager provides null when open() is called without data, so guard (data?.message) before dereferencing the payload.
Styling the programmatic overlay root. Declaratively you write the surface yourself (<div forDialog class="my-dialog">), so the class lands on the same element that carries data-state / role. The manager creates that host for you and it is class-less, so pass class / classList to style it.
this.dialogs.open(ConfirmDialog, { data, alert: true, class: 'my-dialog my-dialog--pop' });
The tokens go on the real [forDialog] host alongside data-state / role / aria-modal, merged and de-duped, never clobbering those attributes.
Enter / exit animations. A programmatic dialog is portaled to document.body and torn down imperatively, so the consumer can't attach animate.leave to the host the way a declarative @if block can. Pass animateEnter / animateLeave (CSS class names) instead: the manager applies animateEnter on mount (via animate.enter) and, on close(), keeps the host mounted with animateLeave until its CSS animations / transitions finish before tearing down. close() still resolves its promise and flips isClosed() immediately. Only the visual teardown waits. With no class (or under prefers-reduced-motion, if your CSS disables the animation) close is immediate.
this.dialogs.open(ConfirmDialog, {
data,
class: 'my-dialog',
animateEnter: 'dialog-in',
animateLeave: 'dialog-out',
});
.my-dialog {
opacity: 1;
transition: opacity 150ms ease-out;
}
.dialog-in {
animation: dialog-fade-in 150ms ease-out;
}
.my-dialog.dialog-out {
opacity: 0;
}
@keyframes dialog-fade-in {
from {
opacity: 0;
}
}
Set them once for a scope with provideForDialogDefaults({ animateEnter, animateLeave }); a per-open() value always wins over the scope default.
| Symbol | Description | |
|---|---|---|
ForDialogManager | Injectable. open(component, config?) returns a ForDialogRef<R>. closeAll(result?) closes every open dialog, topmost first, with reason 'programmatic'; exit animations still play and focus ends where the bottom dialog returns it. openCount is a Signal<number> of the dialogs still open, counting a closed one until its exit animation finishes. | |
ForDialogRef<R> | close(result?), closed: Promise<{ reason: ForDialogCloseReason; result: R | undefined }>, result: Signal<R | undefined>, isClosed: Signal<boolean>. | |
FOR_DIALOG_DATA | Token for the data payload. Inject in the opened component. | |
injectDialogData<T>() | Typed accessor for FOR_DIALOG_DATA. Returns T | null (null when open() got no data). |
ForDialogOpenConfig
| Field | Default | Description | |
|---|---|---|---|
data | — | Payload available as injectDialogData<T>(). | |
dismissible | true | Escape closes when true. | |
modal | true | Sets aria-modal, locks body scroll, traps focus. | |
alert | false | Use role="alertdialog" instead of "dialog". | |
returnFocus | true | Focus returns to the previously focused element on close. | |
initialFocus | 'first' | 'first' finds first focusable; 'container' focuses the host. | |
ariaLabel | — | Manual accessible name when no title element is rendered. | |
animateEnter | — | CSS class applied on mount (via animate.enter) to play an enter animation. | |
animateLeave | — | CSS class applied on close; the host stays mounted until its animation finishes, then tears down. | |
class | — | CSS class(es) applied to the overlay root (the [forDialog] host). Single or space-separated string. | |
classList | — | CSS class(es) applied to the overlay root, as an array or space-separated string. Merged with class. | |
providers | [] | Extra providers for the opened component's injector. | |
autoFocusOnOpen | — | Callback. Receives a VetoableEvent; event.preventDefault() skips the imperative initial focus move. | |
autoFocusOnClose | — | Callback. Receives a VetoableEvent; event.preventDefault() skips the return-focus on close. | |
escapeKeyDown | — | Callback. VetoableNativeEvent<KeyboardEvent>; preventDefault() suppresses the Escape close. | |
pointerDownOutside | — | Callback. VetoableNativeEvent<PointerEvent>; preventDefault() suppresses the outside-pointer close. | |
focusOutside | — | Callback. VetoableNativeEvent<FocusEvent>; preventDefault() suppresses the outside-focus close. | |
interactOutside | — | Callback. Composite VetoableNativeEvent<PointerEvent | FocusEvent>; shares the veto of the two above. |
The four dismiss callbacks mirror the declarative (escapeKeyDown) / (pointerDownOutside) / (focusOutside) / (interactOutside) outputs exactly, with the same events and the same veto semantics. See Per-channel dismissal.
Keyboard
- Escape requests close (reason
'escape') whendismissible. An Escape that cancels an IME composition is left to the IME. - Tab / Shift+Tab cycles focus inside the dialog (focus trap, only when
modal). - Click on
[forDialogBackdrop]requests close (reason'backdrop') whendismissibleand the dialog was the topmost layer as the press began. A press that closes a stacked dialog or a popover open above it leaves this dialog open.
Accessibility
Implements the WAI-ARIA Modal Dialog pattern.
- Always provide an accessible name: render a
[forDialogTitle](setsaria-labelledby) or passariaLabel. A dialog that mounts with neither logs a dev-mode warning (FORCDK-CORE-011) after its first render, because a WCAG-tagged audit does not report the missing name. - Initial focus follows the APG guidance by default (the first focusable element). When the step is hard to undo, or focusing the first control would scroll the start of the content out of view, mark the least destructive action or a static heading carrying
tabindex="-1"with[forDialogInitialFocus]. [forDialogDescription]is optional. Use it for non-title supporting copy (the question of a confirm, the rationale of an alert).alert: trueinterrupts assistive tech aggressively, so use it only for genuine alerts (lost connection, unsaved changes warning), not for general confirms.- Don't put interactive overlays (popovers, menus) outside the focus trap while a modal dialog is open, because they won't be reachable. For a non-modal floating surface anchored to a trigger, use
[forPopover]instead.
Styling
forty-cdk ships no styles: put your own class on each piece and key your CSS off the data-* attributes listed under API, not off the for* selectors (Styling forty-cdk explains why).
This dialog portals to
document.body. CSS scoped to ancestors of[forDialog](or[forDialogBackdrop]) will not apply once the surface is moved to the body. Style it with global CSS or a class. Declaratively you write the surface yourself, so add the class directly (<div forDialog class="my-dialog">); for programmatically opened instances passclass/classListon theForDialogManager.open()config. They land on the same[forDialog]host that carriesdata-state/role/aria-modal, merged and never clobbering them.
.my-dialog {
position: fixed;
inset: 0;
margin: auto;
}
.my-backdrop[data-for-dialog-backdrop] {
position: fixed;
inset: 0;
background: rgb(0 0 0 / 0.5);
}
.anatomy-btn--primary[data-state='open'] {
background: var(--accent);
}
Stacked dialogs. Each surface and its backdrop publish their stacking position as data-depth and --for-dialog-depth (0 for the first dialog, 1 above it, …), shared by declarative and managed dialogs. One rule then covers every level, with each backdrop one step below its own dialog and above every dialog underneath it:
.my-dialog {
z-index: calc(1010 + var(--for-dialog-depth) * 10);
}
.my-backdrop {
z-index: calc(1009 + var(--for-dialog-depth) * 10);
}
CSS custom properties
| Property | Meaning | |
|---|---|---|
--for-dialog-depth | Written on [forDialog] and [forDialogBackdrop]. The dialog's stacking position as a unitless integer, 0 for the first mounted dialog. Fixed for the dialog's lifetime, so a level is never reused. |
Behavior notes
The
(dismiss)contract: the consumer owns unmount.(dismiss)reports the dialog's intent to be unmounted; it does not flip the consumer's signal. The consumer must callopen.set(false)(or equivalent) inside the handler. If the handler is omitted or does not update the signal, Escape, backdrop-click, and outside-pointer-down all emit(dismiss)but the dialog stays mounted.<!-- correct: (dismiss) drives the @if gate --> <div forDialog id="my-dialog" (dismiss)="open.set(false)">…</div> <!-- broken: `(dismiss)` is missing — Escape fires but the dialog never unmounts --> <div forDialog id="my-dialog">…</div>This is different from trigger-anchored overlays (Popover, DropdownMenu, etc.) where the wrapper directive owns
[(open)]and round-trips it automatically on close. Dialog is flat: there is no wrapper, so the consumer's@ifis the sole lifecycle gate. The payload is aForDialogCloseReasonstring ('escape','backdrop','pointerDownOutside','focusOutside','closeButton','programmatic'). Use it if you need to branch on why the dialog closed, for example to show a "save changes?" prompt before dismissing. Emitting(dismiss)without acting on it is always safe: you can callpreventDefault()on the preceding dismiss outputs ((escapeKeyDown),(pointerDownOutside),(focusOutside),(interactOutside)) to suppress the(dismiss)entirely.The declarative and imperative surfaces spell this differently, on purpose. The output is
(dismiss), because an output namedclosewould collide with the native DOM event and break any wrapper re-exposing it throughhostDirectives. Nothing else changes name: the imperative handle method isForDialogRef.close(), the directive selector is[forDialogClose], and the payload type isForDialogCloseReason.Mount equals open. The directive does not manage
[hidden]or any visibility attribute. The consumer's@if (open())controls presence, andanimate.enter/animate.leavehandle the visual transition. That is why[forDialog],[forDialogBackdrop], and[forDialogClose]carry a staticdata-state="open": because the host only exists inside@if (open()), the element is present iff the dialog is open, so the attribute can never beclosed. Exit styling is the consumer'sanimate.leave, not a[data-state="closed"]selector. Only[forDialogTrigger], which stays mounted, togglesopen/closed.Portal: the dialog box is moved to
document.bodyon first render (or tocontainerwhen set). The backdrop portals alongside the dialog (to the samecontainer,document.bodyby default). CSS scoped to ancestors won't apply, so use global styles or classes.Body scroll lock is refcounted: stacking dialogs (or a dialog + a future overlay using the same lock) only restore on the last unlock.
Focus trap scopes Tab inside the dialog box while
modal. It does NOT itself mark the rest of the pageinert. That is the inert-siblings utility's job (next bullet).Inert siblings. When
modal, every direct child ofdocument.bodyother than the dialog box (and its backdrop) getsinertandaria-hidden="true"while open, and is restored on close. This is whataria-modal="true"alone is missing, since Safari + VoiceOver and several other AT pairings otherwise still announce siblings of an aria-modal node. Stacking is order-safe: when a second modal opens on top, the first becomes inert; closing the top dialog re-activates the underlying one.Vetoable dismissals. Each of
(escapeKeyDown),(pointerDownOutside),(focusOutside),(interactOutside)fires before the corresponding(dismiss). CallpreventDefault()on the event to keep the dialog open (e.g. to ask "are you sure?" first).Focus callbacks are inputs, not outputs. The
autoFocusOnOpen/autoFocusOnCloseshape mirrorsForDialogManager'sconfig.autoFocusOn*callbacks and guarantees theautoFocusOnClosecallback fires reliably on every close path, including a directopen.set(false)that bypasses the(dismiss)output. The trigger-anchored overlays (Popover, DropdownMenu, Select, …) expose the same pair as outputs instead, because every close transition there runs through their own[(open)]model, so no path can bypass the emitter.The close button (
[forDialogClose]) always requests close, regardless ofdismissible. Reason emitted is'closeButton'.Both flows share the same engine: the focus trap, scroll lock, dismissible layer, and portal in
ForDialogManager.open()use the same_internal/utilities as the directive. Behavior is identical.
Per-channel dismissal (Escape-only dialogs)
dismissible is not all-or-nothing. The four dismiss channels (Escape, pointer-down-outside, focus-outside, and the composite outside-interaction) are independently vetoable, so you can keep some live and suppress others. The canonical case is a floater (an update banner, a devtools panel) that should close on Escape but stay put when the user clicks elsewhere. Floaters are usually non-modal ([modal]="false"), so the rest of the page stays interactive.
Declarative. Veto the outside channel and leave Escape alone:
@if (open()) {
<div
forDialog
[modal]="false"
(interactOutside)="$event.preventDefault()"
(dismiss)="open.set(false)"
>
…
</div>
}
(interactOutside) fires for both pointer-down-outside and focus-outside and shares their veto, so one handler covers every outside interaction. Escape keeps closing because its channel was never vetoed. To suppress Escape instead, veto (escapeKeyDown).
Programmatic. The same four channels are callbacks on the open config, mirroring the autoFocusOn* shape:
this.dialogs.open(FloaterComponent, {
modal: false,
// dismissible: true is the default — Escape stays live.
interactOutside: (event) => event.preventDefault(), // ignore outside interaction
// escapeKeyDown / pointerDownOutside / focusOutside are available too.
});
Keep dismissible: true (the default) so Escape still closes, and veto only the channels you want to keep open. event.event carries the originating DOM event for inspection.
Open without stealing focus
<input #q type="search" placeholder="Search…" />
@if (open()) {
<div forDialog (dismiss)="open.set(false)" [autoFocusOnOpen]="keepSearchFocused">
<h2 forDialogTitle>Results</h2>
…
</div>
}
readonly keepSearchFocused = (event: VetoableEvent): void => {
event.preventDefault();
this.q().nativeElement.focus();
};
The dialog still installs the focus trap (so Tab cycles inside once focus enters), but the imperative initial focus move is suppressed and the search input keeps focus.
Known limitations
Two shapes are correct by design and still break something a consumer can only discover by hitting it. Both live in markup a design system produces routinely, and neither shows up in devtools: every role and aria-* stays correct, so the symptom is a keyboard or screen-reader one. The library-wide statement, with the same detail for every primitive, is Shadow DOM in forty-cdk/shared.
A shadow host that renders a focusable after its <slot> breaks the trap's Tab cycle. The trap resolves its first / last pair by walking the surface's composed tree, and that walk visits slotted content after the host's whole shadow tree, whereas the browser sequences it at the <slot>'s position. Initial focus can land on a control that is not the visually first one, and a Tab at the dialog's real last control is not recognised as the cycle's end. Focus leaves the surface (with the page inert, usually onto the browser's own UI) and the next Tab is pulled back to whichever control the walk thinks is first. That is the configuration you are in whenever you wrap a third-party web component, or your own ViewEncapsulation.ShadowDom component, inside the dialog. Workaround: render a host's own focusables before its <slot>, or project them instead of shadowing them; initialFocus="container" fixes the initial-focus half only, since the cycle's edges are re-resolved on every Tab press. Details and markup: Focusable order.
A keydown handler inside the dialog that calls stopPropagation() swallows Escape. The dismissible-layer stack observes Escape on document in the bubble phase (a deliberate trade-off recorded on DismissibleLayerStack), so an event stopped inside the surface never arrives, and Escape silently stops dismissing while the backdrop click and [forDialogClose] keep working. Workaround: narrow the stopPropagation() to the keys you actually handle. Keeping the dialog open on Escape is the separate, supported job of the vetoable (escapeKeyDown) output. Details: Escape is observed on the bubble phase.
A third known limit does not apply to this primitive but is easy to hit inside one: a Tabs or Stepper panel rendered in a dialog cannot re-measure its focusable content across a shadow boundary, so its own tab stop can go stale (see that entry).
Scoped / contained dialog
Pass [container] to portal the dialog surface into a specific element instead of document.body. Pair it with [modal]="false" for a dialog scoped to a region of the page.
<section #panel style="position: relative; height: 300px; overflow: hidden;">
@if (open()) {
<div forDialog [modal]="false" [container]="panel" (dismiss)="open.set(false)">
<h2 forDialogTitle>Details</h2>
<button forDialogClose>Close</button>
</div>
}
</section>
CSS contract. The container must be positioned (position: relative); the dialog surface must use position: absolute (not fixed) so it is bounded to the container's box. [forDialogBackdrop] portals to the same container. Use position: absolute on the backdrop too so it fills the container rather than the viewport.
[container] + [modal]="true" gives a region-isolating modal. When both are set, the dialog isolates within the container: focus trap stays scoped to the dialog surface; inert siblings are applied to the container's other children only (body-level siblings outside the container stay interactive); and scroll lock targets the container's own overflow, not <body>. Programmatically: ForDialogManager.open(Cmp, { modal: true, container: panelEl }).
Wrapping in a design system
Subclass the root and re-provide FOR_DIALOG_CONTEXT with useExisting pointing at the subclass, since Angular does not inherit a directive's providers; Wrapping non-form roots walks the pattern.