Build Angular Modal Without Libs - Angular Dynamic Component
Build Angular Modal Without Libs - Angular Dynamic Component

In this post you will learn how to implement a modal inside Angular without any additional libraries.

Finished

Just from the beginning I want to tell you that if you just need a library with a modal you can look an ng-bootstrap or angular-material.

ng-bootstrap

For example ng-bootstrap has a really nice and reusable modal that I used in a lot of places. Both are perfectly fine but if you want to build something yourself (which you can understand completely and can support better) then let's build it from scratch.

The main problem with modals is that we want to render them in body to avoid overflow and z-index problems.

This is why all reusable modals are always rendered in the body.

Initial project

<button>Open modal</button>

Inside app component I have just a button which should open our modal.

// src/app/modal/components/modal/modal.component.html
<div class="modal">
  <div class="modal-header">
    HERE IS TITLE
    <span class="modal-close">✕</span>
  </div>
  <div class="modal-content">
    <ng-content></ng-content>
  </div>
  <div class="modal-footer">
    <button>Submit</button>
  </div>
</div>

<div class="modal-backdrop"></div>

Here is markup of our modal without any logic. This is plain HTML. The only thing here is ng-content which we will use later to render custom template inside.

// src/app/modal/components/modal/modal.component.css
.modal-backdrop {
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.4);
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  bottom: 0;
  z-index: 2;
  display: flex;
  justify-content: center;
  align-items: center;
}

.modal {
  z-index: 3;
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  width: 500px;
  height: auto;
  border: olive 1px solid;
  background-color: white;
  border-radius: 5px;
  padding: 20px;
}

.modal-header {
  display: flex;
  justify-content: space-between;
  font-size: 18px;
  margin-bottom: 20px;
}

.modal-content {
  margin-bottom: 20px;
}

.modal-footer {
  display: flex;
  justify-content: flex-end;
}

.modal-close {
  cursor: pointer;
}

Here are prepared CSS for our form so we fully focus on Angular.

Thinking about architecture

Now we need to think how we want to use our modal. We want to be able to open it from any place and in order to do that we need to use a service. An additional problem is that we want to pass inside a custom template that we want to render as a content.

First let's create a function which opens our modal.

<button (click)="openModal(modalTemplate)">Open modal</button>

<ng-template #modalTemplate>
  <div>This is our custom modal content</div>
</ng-template>

Here we defined a custom template that we pass in openModal function as an argument.

export class AppComponent {
  constructor(private modalService: ModalService) {}

  openModal(modalTemplate: TemplateRef<any>) {
    this.modalService
      .open(modalTemplate, { size: 'lg', title: 'Foo' })
      .subscribe((action) => {
        console.log('modalAction', action);
      });
  }
}

Here is how our openModal function must look like. We inject modalService to open a modal from any place. We pass our custom template in open method and provide additional parameters as a second. Open method returns for us an observable so we can add subscribe we wait when the modal is submitted. This subscribe is our callback.

Modal service

Now we need to create modalService and open method inside.

export class ModalService {
  constructor(
    private resolver: ComponentFactoryResolver,
    private injector: Injector,
    @Inject(DOCUMENT) private document: Document
  ) {}

  open(content: TemplateRef<any>, options?: { size?: string; title?: string }) {
    const modalComponentFactory = this.resolver.resolveComponentFactory(
      ModalComponent
    );
    const contentViewRef = content.createEmbeddedView(null);
    const modalComponent = modalComponentFactory.create(this.injector, [
      contentViewRef.rootNodes,
    ]);
    modalComponent.hostView.detectChanges();
    this.document.body.appendChild(modalComponent.location.nativeElement);
  }
}

Here we created our ModalService and open function. Inside we create modalComponentFactory which knows how to create ModalComponent. Then we created contentViewRef from content that we passed as an argument which will be rendered in ng-content. Then we create modalComponent with this content and called detectChanges. The last line is to append our DOM element to body.

But it is not all. We must pass all events to our ModalComponent.

modalComponent.instance.size = options?.size;
modalComponent.instance.title = options?.title;
modalComponent.instance.closeEvent.subscribe(() => this.closeModal());
modalComponent.instance.submitEvent.subscribe(() => this.submitModal());

modalComponent.hostView.detectChanges();

Before detectChanges we passed size and title to our component. We also subscribed to closeEvent and submitEvent which are outputs inside our modal. We will create this.closeModal and this.submitModal later.

export class ModalService {
  private modalNotifier?: Subject<string>;

  open(...) {
    ...
    modalComponent.hostView.detectChanges();

    this.document.body.appendChild(modalComponent.location.nativeElement);
    this.modalNotifier = new Subject();
    return this.modalNotifier?.asObservable();
  }

  closeModal() {
    this.modalNotifier?.complete();
  }

  submitModal() {
    this.modalNotifier?.next('confirm');
    this.closeModal();
  }
}

Here we created a Subject at the end of open function and returned it as observable. It allows us to subscribe outside and wait for the submit. We also added closeModal where we just complete our subscription and submitModal where we trigger our Subject and complete it.

Modal component

Now we need to implement our Modal. It is relatively easy after our service.

export class ModalComponent {
  @Input() size? = 'md';
  @Input() title? = 'Modal title';

  @Output() closeEvent = new EventEmitter();
  @Output() submitEvent = new EventEmitter();

  constructor(private elementRef: ElementRef) {}

  close(): void {
    this.elementRef.nativeElement.remove();
    this.closeEvent.emit();
  }

  submit(): void {
    this.elementRef.nativeElement.remove();
    this.submitEvent.emit();
  }
}

Here we implemented Input and Output that we set in modalService. We also make correct emits and remove the element from the DOM.

<div class="modal {{ size }}">
  <div class="modal-header">
    {{ title }}
    <span class="modal-close" (click)="close()">✕</span>
  </div>
  <div class="modal-content">
    <ng-content></ng-content>
  </div>
  <div class="modal-footer">
    <button (click)="submit()">Submit</button>
  </div>
</div>

<div class="modal-backdrop" (click)="close()"></div>

This is our finished markup where we binded close, submit and rendered inputs correctly.

Finished

As you can see our modal works just fine, we can open it from any place and all our inputs and outputs are working perfectly.

And actually if you want to improve your Angular knowledge and prepare for the interview I highly recommend you to check my course Angular Interview Questions - Coding Interview 2023.

📚 Source code of what we've done