Primitives
Drawer
A side or bottom sheet built on the modal dialog pattern, adding pointer-driven swipe-to-dismiss and snap points.
Open the drawer, drag it by its edge and let go past the threshold. data-state drives the transition, and data-dragging is set for the gesture itself.
It shares the same focus trap, scroll lock, Escape-to-close, dismissible-layer, and portal behaviors as ForDialog, plus a pointer-driven drag engine.
When to choose
- Drawer: an edge-anchored sheet on the modal dialog engine, with its focus trap, inert background and scroll lock, plus a pointer drag that swipes it away or rests it on a snap point.
- Dialog: the same modal behaviour without the edge anchoring, the drag or the snap points. Choose it for a surface you place with CSS and dismiss with Escape, the backdrop or a close button.
- Popover: non-modal and anchored to its trigger. Choose it when the page behind must stay interactive while the surface is open.
Two flows, one engine
Same engine as Dialog: the directive composes focus trap + scroll lock + dismissible layer + portal + (additionally) swipe-dismiss. Pick declarative or programmatic.
Declarative — [forDrawer]
Mount equals open. The consumer's signal drives @if; the directive emits (dismiss) when it wants to be unmounted.
import { Component, signal } from '@angular/core';
import {
ForDrawer,
ForDrawerBackdrop,
ForDrawerClose,
ForDrawerDescription,
ForDrawerHandle,
type ForDrawerSnapPoint,
ForDrawerTitle,
ForDrawerTrigger,
} from 'forty-cdk/drawer';
@Component({
selector: 'demo-filters',
imports: [
ForDrawer,
ForDrawerTrigger,
ForDrawerBackdrop,
ForDrawerHandle,
ForDrawerTitle,
ForDrawerDescription,
ForDrawerClose,
],
template: `
<button forDrawerTrigger [(open)]="open" controls="filters-drawer">Filters</button>
@if (open()) {
<div
forDrawer
id="filters-drawer"
side="bottom"
[snapPoints]="snaps"
[(activeSnapPoint)]="snap"
(dismiss)="open.set(false)"
animate.enter="slide-up"
animate.leave="slide-down"
>
<div
forDrawerBackdrop
class="drawer-backdrop"
animate.enter="fade-in"
animate.leave="fade-out"
></div>
<div forDrawerHandle aria-hidden="true"></div>
<h2 forDrawerTitle>Filters</h2>
<p forDrawerDescription>Apply filters to the listing.</p>
<button forDrawerClose>Close</button>
</div>
}
`,
})
export class DemoFilters {
readonly open = signal(false);
readonly snaps: ReadonlyArray<ForDrawerSnapPoint> = ['148px', '50%', 1];
readonly snap = signal<ForDrawerSnapPoint | null>(null);
}
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.
Programmatic — ForDrawerManager.open()
The manager mounts the user component underneath the same [forDrawer] directive that powers the declarative shape, so every child piece ([forDrawerTitle], [forDrawerDescription], [forDrawerBackdrop], [forDrawerHandle], [forDrawerClose]) and every ForDrawer input (side, snapPoints, swipeToDismiss, closeThreshold, handleOnly, scaleBackground, setBackgroundColorOnScale, fadeFromIndex, …) work identically. [forDrawerClose] [closeWith] propagates straight through to ForDrawerRef.close(value).
import { Component, inject } from '@angular/core';
import {
ForDrawerBackdrop,
ForDrawerClose,
ForDrawerDescription,
ForDrawerHandle,
ForDrawerManager,
ForDrawerRef,
ForDrawerTitle,
injectDrawerData,
} from 'forty-cdk/drawer';
@Component({
imports: [
ForDrawerBackdrop,
ForDrawerHandle,
ForDrawerTitle,
ForDrawerDescription,
ForDrawerClose,
],
template: `
<div
forDrawerBackdrop
class="drawer-backdrop"
animate.enter="fade-in"
animate.leave="fade-out"
></div>
<div forDrawerHandle aria-hidden="true"></div>
<h2 forDrawerTitle>Delete account?</h2>
<p forDrawerDescription>{{ data?.message }}</p>
<button forDrawerClose [closeWith]="'cancel'">Cancel</button>
<button forDrawerClose [closeWith]="'confirm'">Confirm</button>
`,
})
class ConfirmDrawer {
readonly data = injectDrawerData<{ message: string }>();
readonly ref = inject(ForDrawerRef) as ForDrawerRef<'confirm' | 'cancel'>;
}
@Component({
selector: 'demo-host',
template: `<button (click)="askToDelete()">Delete</button>`,
})
class DemoHost {
readonly #drawers = inject(ForDrawerManager);
async askToDelete(): Promise<void> {
const ref = this.#drawers.open<ConfirmDrawer, 'confirm' | 'cancel'>(ConfirmDrawer, {
data: { message: 'This action cannot be undone.' },
side: 'bottom',
snapPoints: ['148px', 1],
});
const { result } = await ref.closed;
if (result === 'confirm') {
// ...
}
}
}
injectDrawerData<T>() is typed T | null: the manager provides null when open() is called without data, so guard (data?.message) before dereferencing the payload. await ref.closed resolves { reason, result }, whose reason (a ForDrawerCloseReason) tells apart an imperative close() ('programmatic') from Escape / backdrop / outside / swipe / close-button dismissals.
Drawers opened by the manager share the LIFO dismissible layer with declarative ones, so Escape closes the topmost drawer first whichever API opened it. Nesting is resolved from where a drawer is declared, not from the order drawers open in: a declarative [forDrawer] inside a programmatic drawer's component is its child (data-depth="1", and the programmatic parent reflects data-state-nested), but a drawer the manager opens over a declarative one is a root, so it reflects data-depth="0" and its declarative neighbour gets no data-state-nested.
ForDrawerManager.openCount is a Signal<number> of the drawers it has open, counting a closed one until its exit animation finishes. closeAll(result?) closes every one of them, topmost first, with reason 'programmatic': each ref.closed resolves as it does for ref.close(), exit animations still play, and focus ends where the bottom drawer returns it.
Styling the programmatic overlay root. The manager creates the [forDrawer] host for you and it is class-less. Pass class / classList to style it. The tokens land on the real host alongside data-side / data-state / the --for-drawer-swipe-movement-x / -y custom properties, so positioning CSS keyed on data-side works:
this.#drawers.open(ConfirmDrawer, { data, side: 'bottom', class: 'my-drawer' });
.my-drawer[data-side='bottom'] {
inset: auto 0 0 0;
}
Enter / exit animations. A programmatic drawer 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. Set them once for a scope with provideForDrawerDefaults({ animateEnter, animateLeave }); a per-open() value wins over the scope default.
this.#drawers.open(ConfirmDrawer, {
data,
side: 'bottom',
class: 'my-drawer',
animateEnter: 'drawer-in',
animateLeave: 'drawer-out',
});
class is a single or space-separated string; classList is an array or space-separated string; both merge and de-dup and never clobber the host attributes. This replaces the old inject(FOR_DRAWER_CONTEXT).hostElement.classList.add('my-drawer') workaround.
Observing the swipe gesture / active snap point. A snap-point drawer opened imperatively has the same observability as the declarative (swipeStart) / (swipeMove) / (swipeEnd) / (swipeCancel) / (activeSnapPointChange) outputs, via config callbacks of the same name:
this.#drawers.open(ConfirmDrawer, {
data,
snapPoints: ['148px', '50%', 1],
defaultSnapPoint: '148px',
swipeMove: ({ progress }) => this.swipeProgress.set(progress),
swipeEnd: ({ willClose, nextSnapPoint }) => {
/* … */
},
activeSnapPointChange: (snap) => this.activeSnap.set(snap),
});
activeSnapPointChange fires with the landed snap on the mount-time default and every swipe release. It is the read-back that the declarative API exposes through [(activeSnapPoint)]. All subscriptions are released automatically when the drawer closes.
Driving the active snap point. ForDrawerRef.setActiveSnapPoint(snap) moves a snap-point drawer to a new snap after open and is the programmatic equivalent of writing [(activeSnapPoint)] on the declarative [forDrawer]. ref.activeSnapPoint() is the matching reactive read (it also reflects the drawer's own internal transitions, namely the mount-time default and every swipe release):
const ref = this.#drawers.open(ConfirmDrawer, {
data,
snapPoints: ['148px', '50%', 1],
defaultSnapPoint: '148px',
});
ref.setActiveSnapPoint('50%'); // slide to the mid snap
ref.activeSnapPoint(); // => '50%'
Like the declarative model it does not validate the argument against snapPoints (the drag engine tolerates a non-member), it is a no-op once the drawer has closed, and it is meaningful only when snapPoints are configured. The surface movement is the consumer's CSS keyed off the reflected data-active-snap-point attribute (see Positioning the snaps); setActiveSnapPoint only sets the state. As with [(activeSnapPoint)], driving the snap this way does not re-fire the activeSnapPointChange callback (that fires only on the drawer's own internal transitions).
Per-channel dismissal (Escape-only drawers)
dismissible is not all-or-nothing. The four dismiss channels (Escape, pointer-down-outside, focus-outside, and the composite outside-interaction) are independently vetoable on both APIs, so you can keep some live and suppress others (e.g. a non-modal floater that closes on Escape but stays put on an outside click). Programmatically the channels are callbacks on the open config, mirroring the autoFocusOn* shape:
this.#drawers.open(ConfirmDrawer, {
data,
modal: false,
// dismissible: true (the default) keeps Escape live.
interactOutside: (event) => event.preventDefault(), // ignore outside interaction
// escapeKeyDown / pointerDownOutside / focusOutside are available too.
});
Declaratively the same recipe is the four vetoable outputs on [forDrawer]: (interactOutside)="$event.preventDefault()" suppresses the outside-click close while Escape (its own channel) still closes; veto (escapeKeyDown) instead to suppress Escape. Each callback's / output's event.event carries the originating DOM event. The callbacks behave identically to the outputs (same events, same veto semantics) and are torn down with the drawer.
Anatomy
<button forDrawerTrigger [(open)]="open" controls="filters">Filters</button>
<!-- rendered only while open() is true -->
<div forDrawer id="filters" side="bottom" (dismiss)="open.set(false)">
<div forDrawerBackdrop></div>
<div forDrawerHandle aria-hidden="true"></div>
<h2 forDrawerTitle>Filters</h2>
<p forDrawerDescription>Apply filters to the listing.</p>
<button forDrawerClose>Close</button>
</div>
<!-- only when a [scaleBackground] drawer should scale the app shell -->
<div forDrawerWrapper>
<!-- app shell -->
</div>
Examples
Snapping sheet
Drag the sheet between peek / half / full. Release resolves to the nearest snap by position (or dismisses past the lowest one). The consumer positions each snap via CSS keyed off data-active-snap-point; data-dragging disables the transition mid-gesture. fadeFromIndex fades the backdrop in only once the sheet reaches the half snap.
Receding page
With [scaleBackground] the [forDrawerWrapper] element scales and rounds its corners behind the drawer, so the page reads as a layer that recedes. Here the wrapper is the app shell of this documentation site, so the whole page recedes behind the sheet.
Drawer inside a drawer
Open the parent drawer, then the nested one mounted inside its @if: with no flag set, the parent scales back behind the child, and Escape closes the child first, then the parent. Nested drawers covers how nesting is detected, the data-state-nested / data-depth hooks and the @if order the stack relies on.
Region-scoped (container)
The drawer's [container] is this card: open the panel and it slides in over the card only. While it is open, focus stays inside the card and only the card is dimmed, but the page around it still scrolls and responds. Escape or a click on the dimmed area closes it. Scoped / contained drawer has the CSS contract and the non-modal shape.
Project board
This card is the drawer's container. Open the panel — it slides in over this region only, focus stays trapped inside the card, and the page around it keeps working.
Programmatic (ForDrawerManager)
Press the button: the manager opens the confirmation as a bottom drawer, and the line below shows what the awaited ref.closed resolved with. Cancel and Delete close with their own [closeWith] value; Escape or the backdrop close with none, which the demo prints as dismissed. Programmatic documents the config, styling the host and the enter / exit animations.
last result: —
API
ForDrawer
| Property | Type | Description |
|---|---|---|
side | 'top' | 'right' | 'bottom' | 'left' | Anchored edge. Drives swipe direction and data-side.Default: 'bottom' |
modal | boolean | aria-modal, scroll lock, focus trap, inert siblings.Default: true |
dismissible | boolean | Whether Escape / backdrop / outside / swipe close. When false, no swipe dismisses; snapPoints still drag between snaps.Default: true |
alert | boolean | role="alertdialog".Default: false |
returnFocus | boolean | Restore focus on close. Default: true |
initialFocus | 'first' | 'container' | Default: 'first' |
ariaLabel | string | null | Use when no visible title is rendered. Default: null |
autoFocusOnOpen | (e: VetoableEvent) => void | undefined | event.preventDefault() skips the imperative focus move.Default: — |
autoFocusOnClose | (e: VetoableEvent) => void | undefined | Fires on every close path regardless of mode. In non-modal mode the directive doesn't move focus, so the veto is informational; in modal mode event.preventDefault() skips return-focus.Default: — |
swipeToDismiss | boolean | Whether a swipe toward the anchored edge dismisses. When false, snapPoints still drag between snaps and stop at the lowest one. No drag arms under prefers-reduced-motion: reduce.Default: true |
closeThreshold | number | Fraction past which a release dismisses, measured against the full dimension without snapPoints and against the lowest snap's extent with them.Default: 0.25 |
handleOnly | boolean | Swipe arms only on the registered [forDrawerHandle].Default: false |
snapPoints | ReadonlyArray | number ∈ [0,1] | 'NN%' | 'NNpx'. Strictly increasing.Default: — |
activeSnapPoint | ModelSignal | Two-way bindable. Initialised to snapPoints[0] on mount when null.Default: null |
fadeFromIndex | number | Backdrop reflects data-fade-from-active once active >= this index.Default: — |
scaleBackground | boolean | Asks [forDrawerWrapper] to scale + translate behind the drawer.Default: false |
setBackgroundColorOnScale | boolean | Paints <body> to mask the gap between scaled wrapper and viewport edge.Default: true |
dismiss | OutputEmitterRef | Output. Wire to (dismiss)="open.set(false)".Default: — |
escapeKeyDown | OutputEmitterRef | Output. preventDefault() suppresses auto-close.Default: — |
pointerDownOutside | OutputEmitterRef | Output. preventDefault() suppresses auto-close.Default: — |
focusOutside | OutputEmitterRef | Output. preventDefault() suppresses auto-close.Default: — |
interactOutside | OutputEmitterRef | Output. Composite, vetoed by either specific event. Default: — |
swipeStart | OutputEmitterRef | Output. Fires once on the arming pointer move; progress is 0.Default: — |
swipeMove | OutputEmitterRef | Output. Streams progress ∈ [0,1] and the originating PointerEvent.Default: — |
swipeEnd | OutputEmitterRef | Output. willClose, nextSnapPoint. Directive already updated state.Default: — |
swipeCancel | OutputEmitterRef | Output. pointercancel or mid-gesture direction abort; the surface springs back.Default: — |
ForDrawerCloseReason: 'escape' | 'backdrop' | 'pointerDownOutside' | 'focusOutside' | 'closeButton' | 'swipe' | 'programmatic'.
Enter and exit animations are not inputs. On a declarative drawer, put animate.enter / animate.leave on the [forDrawer] host inside its @if; on a programmatic one, pass animateEnter / animateLeave to ForDrawerManager.open().
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 isForDrawerRef.close(), the directive selector is[forDrawerClose], and the payload type isForDrawerCloseReason.
| Data attribute | Values | |
|---|---|---|
data-state | open | |
data-side | top | right | bottom | left | |
data-active-snap-point | the active snap point stringified, or absent | |
data-dragging | present / absent | |
data-scale-background | present / absent | |
data-depth | 0 (root) | 1 (first child) | … | |
data-state-nested | present / absent |
ForDrawerBackdrop
| Data attribute | Values | |
|---|---|---|
data-state | open | |
data-fade-from-active | present / absent | |
data-dragging | present / absent | |
data-depth | its drawer's data-depth |
ForDrawerInitialFocus
[forDrawerInitialFocus] marks the element that receives focus when the enclosing drawer opens, in place of the one initialFocus picks. It takes no inputs, and it works inside a component opened with ForDrawerManager.open() as well. 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 drawer: a second marker warns in dev mode and only the newest is used.
ForDrawerTrigger
| Data attribute | Values | |
|---|---|---|
data-state | open | closed | |
data-disabled | present / absent |
ForDrawerClose
| Data attribute | Values | |
|---|---|---|
data-state | open |
ForDrawerWrapper
| Data attribute | Values | |
|---|---|---|
data-state | scaled | idle |
Snap points
Three accepted shapes:
number ∈ [0, 1]: fraction of the dismissal-axis dimension.'NN%': equivalent to a fraction ('50%' === 0.5).'NNpx': absolute pixel size measured from the anchored edge.
Pass them in strictly increasing order (closest-to-edge first); in dev mode the directive throws FORCDK-DRAWER-009 otherwise. Mixed units ('200px' next to 0.5) can only be ordered against the live drawer size, so they are re-checked on first measurement and fail with FORCDK-DRAWER-010, which names the offending point and the dimension it resolved against. fadeFromIndex must be a valid index into snapPoints.
<div
forDrawer
class="sheet"
[snapPoints]="['148px', '50%', 1]"
[(activeSnapPoint)]="snap"
[fadeFromIndex]="1"
></div>
The three snaps read peek → mid → full; snap is the current one, written on every swipe release; the backdrop fades once the active snap reaches the second index.
The model<>() change emitter ((activeSnapPointChange)) fires on internal transitions (the mount-time default and every swipe release), and stays silent on consumer writes through [(activeSnapPoint)].
Positioning the snaps (CSS contract)
The directive does not position the surface at each snap. That is the consumer's job, keyed off data-active-snap-point. Position the rest state with a layout property such as bottom / top (or left / right), and transition it for the snap-to-snap animation.
The live swipe delta is published on the host as two px custom properties, --for-drawer-swipe-movement-x and --for-drawer-swipe-movement-y (0px at rest; only the drawer's dismissal axis is ever non-zero). Compose the shorthand yourself on the surface with translate: var(--for-drawer-swipe-movement-x, 0px) var(--for-drawer-swipe-movement-y, 0px). The directive uses custom properties instead of writing translate / transform directly, for two reasons: transform is reserved for the scale-background / nested effect, and a directly-written inline translate is silently dropped by Angular when you also bind a template [style.*] on the same host. Reading them through the vars keeps the gesture working regardless of any inline style bindings you put on the surface, and composes with transform without clobbering it.
For a seamless release, transition both translate and your snap-position property with the same timing, and suppress that transition while data-dragging is present. The directive resets both movement properties to 0px, removes data-dragging, and updates data-active-snap-point in a single change-detection pass on release, so the swipe delta animates back to zero in lockstep with the snap-position change. The surface never jumps to the previous rest position before sliding to the new snap.
.sheet {
/* The directive publishes the live swipe delta here; compose it on the surface. */
translate: var(--for-drawer-swipe-movement-x, 0px) var(--for-drawer-swipe-movement-y, 0px);
}
.sheet[data-active-snap-point] {
height: 80vh;
transition:
bottom 0.42s cubic-bezier(0.32, 0.72, 0, 1),
translate 0.42s cubic-bezier(0.32, 0.72, 0, 1);
}
.sheet[data-active-snap-point][data-dragging] {
transition: none; /* the drag follows the pointer 1:1 */
}
.sheet[data-active-snap-point='148px'] {
bottom: calc(148px - 80vh);
}
.sheet[data-active-snap-point='0.5'] {
bottom: -40vh;
}
.sheet[data-active-snap-point='1'] {
bottom: 0;
}
Backdrop swipe-fade (CSS contract)
[forDrawerBackdrop] publishes the live swipe progress toward the anchored edge as the --for-drawer-swipe-progress custom property (0 at rest → 1 fully swiped off-screen) and mirrors the surface's data-dragging attribute. This drives the "backdrop fades out as you swipe to dismiss" cue with pure CSS and needs no (swipeMove) listener:
.drawer-backdrop {
/* Fades the backdrop as the surface is swiped off-screen. */
opacity: calc(1 - var(--for-drawer-swipe-progress, 0));
transition: opacity 0.3s ease;
}
.drawer-backdrop[data-dragging] {
transition: none; /* track the pointer 1:1 during the gesture */
}
--for-drawer-swipe-progress only reflects the dismiss direction: with snap points, a drag away from the edge (growing the surface) keeps it at 0. On release it resets to 0 in the same change-detection pass that flips data-dragging off, so the backdrop animates back to full opacity in lockstep with the surface settling. The snap-driven data-fade-from-active cue (see above) is independent and can be combined or used on its own.
Swipe-to-dismiss
- Pointer drag toward the anchored edge translates the surface and resolves to the nearest snap (or a dismiss) on release.
- With
snapPoints, the drag is bidirectional: a drag away from the anchored edge grows the surface toward a larger snap (bounded by the largest snap), and a drag toward the edge shrinks it / dismisses past the lowest one. WithoutsnapPointsthe gesture is one-way (toward the edge to dismiss). - Dismissal and dragging are separate. With
swipeToDismissordismissibleset tofalse, a drawer withsnapPointsstill drags between them and stops at the lowest snap, and(swipeEnd)reportswillClose: false; a drawer withoutsnapPointsarms no drag at all. closeThreshold(default0.25) is the fraction past which a release from the lowest snap dismisses. It is measured against that snap's own extent (not the full dimension), so a small "peek" snap stays dismissible without dragging it off-screen.handleOnly: trueconfines the gesture to a registered[forDrawerHandle], leaving the rest of the surface free for content scroll.- Gestures starting inside a scrollable element that hasn't reached its edge are NOT treated as swipes (the helper defers to inner scroll).
prefers-reduced-motion: reducedisables the drag entirely, snap dragging included. Escape, backdrop, outside-pointer, and close button continue to work.
Scale background
Opt in to the "viewport recedes behind the drawer" effect: when the drawer opens, the rest of the app shrinks slightly and rounds its corners to read as a layered surface. Two pieces required:
- Apply
[forDrawerWrapper]on the element that wraps the rest of the app (typically the root shell). Only one wrapper may be registered at a time. - Set
[scaleBackground]="true"on the drawer that should drive the effect.
<!-- Root shell -->
<div forDrawerWrapper>
<header>…</header>
<main>…</main>
</div>
<!-- Anywhere in the tree -->
@if (open()) {
<div
forDrawer
side="bottom"
[scaleBackground]="true"
(dismiss)="open.set(false)"
animate.enter="slide-up"
animate.leave="slide-down"
>
…
</div>
}
While the effect is active the wrapper reflects data-state="scaled" (and "idle" at rest); the drawer reflects data-scale-background so consumers can style the surface differently when scale is in play (e.g. larger corner radii).
setBackgroundColorOnScale (default true) paints <body> with scaleBackgroundColor while the effect is active. Disable it ([setBackgroundColorOnScale]="false") when the application shell already covers the viewport edge: a themed <html> / <body> background, a fixed root layer, or a full-bleed CSS-framework wrapper. In those flows the body-color mutation is redundant and would briefly overwrite a theme-managed value on every open / close; leaving it off keeps the consumer's own paint authoritative, the rounded gap behind the scaled wrapper composes with whatever colour they ship. The flag has no effect under prefers-reduced-motion: reduce (the whole effect is suppressed).
prefers-reduced-motion: reduce suppresses the effect entirely. Wrapper styles, body color, and data-scale-background are all bypassed without affecting the rest of the drawer's behaviour.
Tune the magic numbers via provideForDrawerDefaults (scaleAmount, scaleTranslateYpx, scaleBorderRadiusPx, scaleBackgroundColor).
Nested drawers
A drawer mounted inside another drawer's @if is automatically detected as a child and joins a LIFO stack. No nested flag is required. The directive composes the existing dismissible-layer / focus / scroll-lock stacks (Escape closes the topmost first; focus stays trapped in the topmost; body scroll lock is refcounted so closing the child does not unlock the parent), and adds two visual hooks on the parent surface:
data-state-nested(present / absent) while at least one descendant is registered. This is useful for styling the parent differently when it is "covered" by a child. Style it with[data-state-nested], never[data-state-nested="true"].- An inline
transform: scale(N) translate3d(...)that scales the parent surface and translates it slightly away from its anchored edge, so the child reads as a layer in front. Suppressed underprefers-reduced-motion: reduce. Tune vianestedScaleAmount(default0.93) andnestedTranslateYpx(default8).
Each drawer also reflects its position in the stack as data-depth ("0" for the root, "1" for the first child, …).
@if (parentOpen()) {
<div forDrawer side="bottom" (dismiss)="parentOpen.set(false)" animate.leave="slide-down">
<h2 forDrawerTitle>Filters</h2>
<button (click)="childOpen.set(true)">Date range</button>
@if (childOpen()) {
<div forDrawer side="bottom" (dismiss)="childOpen.set(false)" animate.leave="slide-down">
<h2 forDrawerTitle>Date range</h2>
…
</div>
}
</div>
}
Always nest the child's @if inside the parent's @if. That guarantees Angular's bottom-up destroy order tears the child down before the parent. The topology stack throws otherwise, so the bug is loud at dev time. If both drawers opt into [scaleBackground]="true", the wrapper effect composes with the parent's nested transform automatically.
Scoped defaults
import { provideForDrawerDefaults } from 'forty-cdk/defaults';
bootstrapApplication(App, {
providers: [
provideForDrawerDefaults({
side: 'right',
closeThreshold: 0.4,
handleOnly: true,
// Scale-background (opt-in per drawer; the keys below tune the visual)
scaleAmount: 0.93,
scaleTranslateYpx: 16,
scaleBorderRadiusPx: 12,
scaleBackgroundColor: '#000',
}),
],
});
Per-component overrides nest:
@Component({
providers: [provideForDrawerDefaults({ side: 'left' })],
// ...
})
Scoped / contained drawer
Pass [container] to portal the surface and the backdrop into a specific element instead of document.body. The supported shape is [container] paired with [modal]="false".
<section
#listBox
data-testid="container"
style="position: relative; height: 400px; overflow: hidden;"
>
<button forDrawerTrigger [(open)]="open">Open</button>
@if (open()) {
<div forDrawer side="right" [modal]="false" [container]="listBox" (dismiss)="open.set(false)">
<div forDrawerBackdrop></div>
<h2 forDrawerTitle>Filters</h2>
<button forDrawerClose>Close</button>
</div>
}
</section>
CSS contract. The container must be positioned (position: relative); the surface and backdrop must use position: absolute (not fixed) so they are bounded to the container's box:
section[data-testid='container'] {
position: relative;
}
[forDrawer] {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 300px;
background: #fff;
}
[forDrawerBackdrop] {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
}
[container] + [modal]="true": region-isolating modal. When modal is true alongside container, the drawer isolates within the container:
- Focus trap stays scoped to the drawer surface (unchanged from non-contained modal mode).
- Inert siblings are applied to the container's other children only. Body-level siblings outside the container stay fully interactive.
- Scroll lock targets the container's own
overflow, not<body>, so the rest of the page keeps scrolling.
<section
#listBox
data-testid="container"
style="position: relative; height: 400px; overflow: auto;"
>
<button forDrawerTrigger [(open)]="open">Open</button>
@if (open()) {
<div forDrawer side="right" [modal]="true" [container]="listBox" (dismiss)="open.set(false)">
<div forDrawerBackdrop></div>
<h2 forDrawerTitle>Filters</h2>
<button forDrawerClose>Close</button>
</div>
}
</section>
Programmatic equivalent. ForDrawerManager.open(Cmp, { modal: true, container: boxEl }) portals both the surface and any [forDrawerBackdrop] inside the opened component into boxEl and scopes all three isolation behaviours to it.
Swipe-to-dismiss and snap points keep working inside a container, because the math is dimension-based (getBoundingClientRect), not viewport-based.
scaleBackground / nested visual transforms assume a full-screen model and are not meaningful inside a container.
Mount/unmount and animations
The directive deliberately does not apply [hidden] to its surface. Wrap with @if (open()) and use Angular's native animate.enter / animate.leave for transitions. data-state="open" reflects the logical state for CSS hooks but is never tied to visibility, which is @if's job.
@if (open()) {
<div
forDrawer
side="bottom"
(dismiss)="open.set(false)"
animate.enter="slide-up"
animate.leave="slide-down"
>
…
</div>
}
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 drawer'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 drawer. 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 drawer 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 swipe-to-dismiss, the backdrop click and [forDrawerClose] keep working. Only the topmost drawer's Escape is affected; see Nested drawers for the stacking contract. Workaround: narrow the stopPropagation() to the keys you actually handle. Keeping the drawer 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 drawer cannot re-measure its focusable content across a shadow boundary, so its own tab stop can go stale. See that entry.
Accessibility
Implements the WAI-ARIA Modal Dialog pattern. role="dialog" (or "alertdialog" when alert), aria-modal="true" in modal mode, aria-labelledby / aria-describedby auto-wired by [forDrawerTitle] / [forDrawerDescription]. Modal mode applies inert and aria-hidden="true" to body siblings so AT cannot reach them. The handle is aria-hidden="true" because keyboard users dismiss via Escape or [forDrawerClose].
A drawer that mounts with neither a [forDrawerTitle] nor an ariaLabel logs a dev-mode warning (FORCDK-CORE-011) after its first render. To land focus somewhere other than the first focusable element on open, such as the least destructive action or a static heading carrying tabindex="-1", mark it with [forDrawerInitialFocus].
Keyboard: Escape closes the topmost drawer when dismissible, unless it cancels an IME composition; Tab / Shift+Tab cycles focus inside the drawer when modal; Click on [forDrawerBackdrop] closes when dismissible and the drawer was the topmost layer as the press began, so a press on a parent's backdrop closes only the child drawer above it.
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 is a modal overlay: the surface and backdrop portal to
document.body. Style them with global CSS or classes. Declaratively, add your class to the surface element (<div forDrawer class="my-drawer">); for drawers opened withForDrawerManager.open(), passclass/classListon the open config so the tokens land on the real[forDrawer]host.
CSS custom properties
| Property | Meaning | |
|---|---|---|
--for-drawer-swipe-movement-x | Written on [forDrawer] (the surface). Live swipe displacement in CSS px along the x axis (0px at rest; only the dismissal axis is ever non-zero). Compose with translate: var(--for-drawer-swipe-movement-x, 0px) var(--for-drawer-swipe-movement-y, 0px). See Positioning the snaps. | |
--for-drawer-swipe-movement-y | Written on [forDrawer] (the surface). Live swipe displacement in CSS px along the y axis (0px at rest; only the dismissal axis is ever non-zero). Compose with translate: var(--for-drawer-swipe-movement-x, 0px) var(--for-drawer-swipe-movement-y, 0px). See Positioning the snaps. | |
--for-drawer-swipe-progress | Written on [forDrawerBackdrop]. Swipe progress toward the anchored edge, a unitless fraction 0 (at rest) → 1 (fully swiped off-screen). Fade with opacity: calc(1 - var(--for-drawer-swipe-progress, 0)). See Backdrop swipe-fade. | |
--for-drawer-depth | Written on [forDrawer] and [forDrawerBackdrop]. The drawer's nesting depth as a unitless integer, 0 for a root drawer, mirroring data-depth. Stack a nested drawer's backdrop above its parent with z-index: calc(1009 + var(--for-drawer-depth) * 10). |
.sheet[data-active-snap-point] {
transition: translate 0.42s cubic-bezier(0.32, 0.72, 0, 1);
}
.sheet[data-dragging] {
transition: none;
}
Wrapping in a design system
Subclass the root and re-provide FOR_DRAWER_CONTEXT with useExisting pointing at the subclass, since Angular does not inherit a directive's providers; Wrapping non-form roots walks the pattern.