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

For most projects Bootstrap JS modals work fine. Use ng-bootstrap NgbModal if you need deep Angular integration — reactive forms inside modals with proper change detection, or if you're already using ng-bootstrap for other components. For simple dialogs Bootstrap JS is simpler.
Set data-bs-backdrop='static' on the modal element or pass { backdrop: 'static' } when initializing: new bootstrap.Modal(el, { backdrop: 'static' }). To also prevent ESC key closing add keyboard: false option.
The cleanest approach is a shared service. Set data on the service before showing the modal, then read it inside the modal component or in the shown.bs.modal event handler.
Inject the modal element reference and call bootstrap.Modal.getInstance(el).hide(). Or use a service to manage modal state. Or use data-bs-dismiss='modal' on a button inside the modal which Bootstrap handles automatically.
Yes. Angular reactive forms work inside Bootstrap modals without issues. The form component is rendered as normal Angular template content inside the modal-body. Change detection and form validation work correctly.

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.