What this guide covers

In an earlier piece—Angular routed dialogs—the case for placing dialogs behind routes is laid out. Just like any route-connected component, dialogs can be treated as pages. That post explores several strategies for managing dialogs, with an emphasis on why routing them is advantageous.

Where we start

We pick up from the same setup used in that earlier article. There's a UsersComponent that renders a user list. A companion UsersDetailComponent shows the details for a chosen user. When a user clicks an entry in the list, the details should appear. The detail view relies on a shared MyDialogComponent to present that information in a dialog. If you haven’t read that first post, it’s worth checking out before continuing, since we're using the exact same project structure.

In this post, the focus shifts to implementing real dialog behavior with the Angular CDK.

Why choose the Angular CDK?

The Angular CDK is built around the behaviors that web applications commonly need. It covers accessibility, coercion, drag and drop, and much more. A notable perk is that the Angular Material team maintains it.

The CDK gives us two building blocks for constructing modals:

  1. The portal: a chunk of UI that can be injected dynamically into an available slot on the page.
  2. The overlay: which provides dialog-like behavior, including position strategies, backdrop handling, and other helpful utilities. A key detail is that it appends a cdk-overlay-container div to the end of the body, and the dialog lives inside it. As a result, an overlay won’t be cut off by any parent with overflow: hidden.

Setup

Start by adding the @angular/cdk package with:

npm i @angular/cdk --save

Since we plan to use both the portal and overlay, we have to bring in the PortalModule and the OverlayModule in the AppModule. For those working with standalone components, these modules go into the imports property of each component instead.

The overlay needs a few prebuilt CDK styles to look right, including the backdrop. Add the following to styles.css:

@import '@angular/cdk/overlay-prebuilt.css';

Building the dialog component

If you followed the previous article, you likely already have a my-dialog component in place. Here’s how we typically use it:

<my-dialog>
    <ng-container my-dialog-header>Here is my header</ng-container>
    <ng-container my-dialog-body>Here is my body</ng-container>
</my-dialog>

Because the content ends up rendered inside an overlay-container, we have to turn off style encapsulation. Otherwise, the component's styles won't reach the portal. The key is setting the encapsulation to ViewEncapsulation.None:

@Component({
    selector: 'my-dialog',
    ...
    encapsulation: ViewEncapsulation.None
})

The dialog template

The component's template looks like this:

<ng-template cdkPortal>
    <div class="dialog">
        <div class="dialog__header">
            <ng-content select="[my-dialog-header]"></ng-content>
            <button (click)="closeDialog.emit()">Close</button>
        </div>
        <div class="dialog__body">
            <ng-content select="[my-dialog-body]"></ng-content>
        </div>
    </div>
</ng-template>

The whole thing sits inside an ng-template that carries the cdkPortal directive. Later, we’ll use a ViewChild query in the class to grab a reference to it. There are also two ng-content slots for projecting the header and body. A close button in the header triggers the closeDialog output, which alerts the parent that the dialog should be torn down.

The dialog class

First, we need an overLayRef. The CDK’s Overlay service has a create() method that returns one. That method accepts an overlayConfig argument where we can set its position, width, backdrop, and other options.

export class MyDialogComponent {
    private readonly overlayConfig = new OverlayConfig({
        // show backdrop
        hasBackdrop: true,
        // position the dialog in the center of the page
        positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically(),
        // when in the dialog, block scrolling of the page      
        scrollStrategy: this.overlay.scrollStrategies.block(),
        minWidth: 500,
    });
    private overlayRef = this.overlay.create(this.overlayConfig);

    constructor(private readonly overlay: Overlay){
    }
}

Once we have the overlayRef, we attach the portal to it. This allows the portal’s contents to actually show up inside the overlay. The attachment has to happen after the view is ready, so the ngAfterViewInit lifecycle hook is the right place. A @ViewChild(CdkPortal) query gives us a handle to the portal declared in the template:

export class MyDialogComponent implements OnInit, AfterViewInit {
    // get a grasp on the ng-template with the cdkPortal directive
    @ViewChild(CdkPortal) public readonly portal: CdkPortal | undefined;

    private readonly overlayConfig = new OverlayConfig({...});
    private overlayRef = this.overlay.create(this.overlayConfig);
    constructor(private readonly overlay: Overlay){
    }
    
    public ngAfterViewInit(): void {
        // Wait until the view is initialized to attach the portal to the overlay
        this.overlayRef?.attach(this.portal);
    }
}

That covers getting the dialog on screen, but we still need to think about cleanup. The component itself doesn’t close itself—that's up to its parent, through an *ngIf or a route change. Still, we have to release the overlayRef when the component goes away. That means calling its detach() and dispose() methods, which we do in the ngOnDestroy hook:


export class MyDialogComponent implements OnInit, AfterViewInit, OnDestroy {
    // Tell the parent to destroy the component
    @Output() public readonly closeDialog = new EventEmitter<void>();

    @ViewChild(CdkPortal) public readonly portal: CdkPortal | undefined;
    ...
    public ngOnDestroy(): void {
        // parent destroys this component, this component destroys the overlayRef
        this.overlayRef?.detach();
        this.overlayRef?.dispose();
    }
}

Notice the closeDialog output added here. The template invokes it whenever the close button is pressed.

Handling backdrop clicks

The close button is one way to signal the parent that the dialog should go away. Another is letting the user click outside it, on the backdrop.

The overlayRef comes with a backdropClick() method, which returns an observable that fires whenever there’s a click on the backdrop. We can listen to that and emit on the closeDialog EventEmitter. This subscription lives in the constructor:

constructor(...) {
    this.overlayRef?.backdropClick()
        .subscribe(() => {
            this.closeDialog.emit();
        });
}

Wrapping up

Here is the final version of the MyDialog component class in full:

export class MyDialogComponent implements AfterViewInit {
    // get a grasp on the ng-template with the cdkPortal directive 
    @ViewChild(CdkPortal) public readonly portal: CdkPortal | undefined;
    // the parent is in charge of destroying this component (usually through ngIf or route change)
    @Output() public readonly closeDialog = new EventEmitter<void>();
    
    // the configuration of the overlay
    private readonly overlayConfig = new OverlayConfig({
        hasBackdrop: true,
        positionStrategy: this.overlay
            .position()
            .global()
            .centerHorizontally()
            .centerVertically(),
        scrollStrategy: this.overlay.scrollStrategies.block(),
        minWidth: 500,
    });
    // creating the overlayRef
    private overlayRef = this.overlay.create(this.overlayConfig);

    constructor(private readonly overlay: Overlay) {
        // telling the parent to destroy the dialog when the user
        // clicks on the backdrop
        this.overlayRef.backdropClick().subscribe(() => {
            this.closeDialog.emit();
        });
    }
    
    // attach the portal to the overlayRef when the view is initialized
    public ngAfterViewInit(): void {
        this.overlayRef?.attach(this.portal);
    }

    public ngOnDestroy(): void {
        // When the parent destroys this component, this component destroys the overlayRef
        this.overlayRef?.detach();
        this.overlayRef?.dispose();
    }
}

Thanks for staying with me through this concise guide. I trust it proves useful.

A working Stackblitz demo is available below:

Acknowledgements

Angular forms course