Modals in Angular + Bootstrap are one of those things that work fine if you wire them up correctly and cause headaches if you don't. The main issue is Bootstrap's modal JS runs outside Angular's change detection cycle.
Here's the approach I use on production projects.
Option 1: Simple Modal with Bootstrap JS (Recommended for Most Cases)
For a basic modal — confirmation dialogs, info popups, simple forms — just use Bootstrap's JS directly. It works fine.
Template:
<!-- modal-demo.component.html --> <!-- Trigger button --> <button class="btn btn-primary" (click)="openModal()"> Open Modal </button> <!-- Modal HTML --> <div class="modal fade" id="demoModal" tabindex="-1" #demoModal> <div class="modal-dialog"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title">{{ modalTitle }}</h5> <button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div> <div class="modal-body"> {{ modalMessage }} </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal"> Cancel </button> <button type="button" class="btn btn-primary" (click)="onConfirm()"> Confirm </button> </div> </div> </div> </div>
Component:
// modal-demo.component.ts import { Component, ElementRef, ViewChild, OnDestroy } from '@angular/core' import { CommonModule } from '@angular/common' declare const bootstrap: any @Component({ selector: 'app-modal-demo', standalone: true, imports: [CommonModule], templateUrl: './modal-demo.component.html' }) export class ModalDemoComponent implements OnDestroy { @ViewChild('demoModal') modalElement!: ElementRef modalTitle = 'Confirm Action' modalMessage = 'Are you sure you want to proceed?' private modalInstance: any = null openModal() { this.modalInstance = new bootstrap.Modal(this.modalElement.nativeElement) this.modalInstance.show() } onConfirm() { console.log('Confirmed!') this.modalInstance?.hide() } ngOnDestroy() { // Clean up to prevent memory leaks this.modalInstance?.dispose() } }
The declare const bootstrap: any is needed because Bootstrap JS is loaded globally via angular.json scripts.
Option 2: Modal Service (For Complex Apps)
For apps where you need to open modals programmatically from services or other components, create a modal service:
// modal.service.ts import { Injectable } from '@angular/core' declare const bootstrap: any @Injectable({ providedIn: 'root' }) export class ModalService { private modals: Map<string, any> = new Map() open(id: string) { const el = document.getElementById(id) if (!el) return let modal = this.modals.get(id) if (!modal) { modal = new bootstrap.Modal(el) this.modals.set(id, modal) } modal.show() } close(id: string) { this.modals.get(id)?.hide() } dispose(id: string) { this.modals.get(id)?.dispose() this.modals.delete(id) } }
Use anywhere:
constructor(private modal: ModalService) {} deleteItem() { this.modal.open('confirmModal') }
Confirmation Dialog Pattern
This is the most common modal use case in admin dashboards:
<!-- confirm-dialog.component.html --> <div class="modal fade" id="confirmDialog" tabindex="-1" #confirmDialog> <div class="modal-dialog modal-dialog-centered"> <div class="modal-content border-0"> <div class="modal-body text-center p-4"> <div style="font-size:3rem;" class="mb-3">⚠️</div> <h5 class="fw-bold mb-2">{{ title }}</h5> <p class="text-muted mb-4">{{ message }}</p> <div class="d-flex gap-2 justify-content-center"> <button class="btn btn-secondary px-4" data-bs-dismiss="modal"> Cancel </button> <button class="btn btn-danger px-4" (click)="confirm()"> {{ confirmText }} </button> </div> </div> </div> </div> </div>
import { Component, ElementRef, ViewChild, Output, EventEmitter, Input } from '@angular/core' declare const bootstrap: any @Component({ selector: 'app-confirm-dialog', standalone: true, templateUrl: './confirm-dialog.component.html' }) export class ConfirmDialogComponent { @ViewChild('confirmDialog') el!: ElementRef @Input() title = 'Are you sure?' @Input() message = 'This action cannot be undone.' @Input() confirmText = 'Delete' @Output() confirmed = new EventEmitter<void>() private modal: any open() { this.modal = new bootstrap.Modal(this.el.nativeElement) this.modal.show() } confirm() { this.confirmed.emit() this.modal.hide() } }
Use in parent:
<button class="btn btn-danger" (click)="confirmDelete.open()">Delete</button> <app-confirm-dialog #confirmDelete title="Delete Template" message="This will permanently delete the template and all its data." confirmText="Yes, Delete" (confirmed)="onDeleteConfirmed()"> </app-confirm-dialog>
Reactive Form Inside a Modal
Angular reactive forms work perfectly inside Bootstrap modals:
<div class="modal fade" id="editModal" tabindex="-1" #editModal> <div class="modal-dialog"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title">Edit Profile</h5> <button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div> <div class="modal-body"> <form [formGroup]="editForm"> <div class="mb-3"> <label class="form-label">Name</label> <input type="text" class="form-control" formControlName="name" [class.is-invalid]="editForm.get('name')?.invalid && editForm.get('name')?.touched"> <div class="invalid-feedback">Name is required.</div> </div> <div class="mb-3"> <label class="form-label">Email</label> <input type="email" class="form-control" formControlName="email" [class.is-invalid]="editForm.get('email')?.invalid && editForm.get('email')?.touched"> <div class="invalid-feedback">Valid email required.</div> </div> </form> </div> <div class="modal-footer"> <button class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button class="btn btn-primary" (click)="saveForm()" [disabled]="editForm.invalid"> Save Changes </button> </div> </div> </div> </div>
import { Component, ElementRef, ViewChild } from '@angular/core' import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms' declare const bootstrap: any @Component({ standalone: true, imports: [ReactiveFormsModule], // ... }) export class EditModalComponent { @ViewChild('editModal') el!: ElementRef editForm: FormGroup constructor(private fb: FormBuilder) { this.editForm = this.fb.group({ name: ['', Validators.required], email: ['', [Validators.required, Validators.email]] }) } open(data: { name: string; email: string }) { this.editForm.patchValue(data) new bootstrap.Modal(this.el.nativeElement).show() } saveForm() { if (this.editForm.valid) { console.log(this.editForm.value) // save logic here } } }
The form's change detection runs normally because it's part of Angular's component tree. No ChangeDetectorRef.detectChanges() needed.
One Thing to Watch
When using @ViewChild the element reference is only available after ngAfterViewInit. Don't try to instantiate Bootstrap modals in ngOnInit — use ngAfterViewInit or lazy-initialize in the open method as shown above.
Frequently Asked Questions
Related
Need a Full Angular + Bootstrap Admin Dashboard?
Marvel Angular Dashboard — Angular 21 + Bootstrap 5 with 50+ components and dark mode.
Browse Templates →Use code FIRST30 for 30% off.