ngx-modalieur

August 11, 2026 · View on GitHub

Reactive Bootstrap modals for Angular — a thin, typed layer on CDK Dialog.

npm version npm downloads Angular License Live demo

npm install ngx-modalieur @angular/cdk bootstrap

Open a modal, subscribe to the result. No modal ids, no global result bus, no boilerplate.

Try the live demo — interactive examples and a config playground in your browser.


Need lightweight notifications alongside your modals? Check out ngx-toaster-next — accessible toast notifications for Angular 22 with familiar ngx-toastr-style visuals and a small API. Both libraries target Angular 22 and are designed as focused UI building blocks: use ngx-modalieur for interactions that require a response, and ngx-toaster-next for non-blocking feedback. Contributions, bug reports, and ideas are welcome in both projects.


Table of contents

What is this?

ngx-modalieur wraps Angular CDK Dialog with a Bootstrap 5.3 shell, a standardized ModalOutcome result model, and convenience APIs for confirm / alert dialogs.

It is not a replacement for CDK Dialog. Focus trapping, overlay positioning, backdrop, and Escape handling still come from CDK. This library adds the Bootstrap markup, bridging CSS, reactive close semantics, and message-box shortcuts so you do not rebuild that glue in every app.

New to Angular modals? Start with Quick start — you will have a working confirm dialog in under a minute.

Why use it?

Raw CDK Dialogngx-modalieur
StylingBring your own container and CSSBootstrap .modal-dialog shell + bridging styles
Close resultdialogRef.close(value) — shape is yoursStandardized { result: ModalResult; data? }
Message boxesBuild yourselfconfirm(), alert(), messageBox()
App-wide defaultsDIY injection tokenprovideModalieur({ … })
Auto-close on streamsWire takeUntil + close() yourselfshowUntil() / showUntilCondition()

Subscribe, don't wire. show() returns Observable<ModalOutcome>. Open a modal, react in one subscribe or pipe — no modal ids, no global result bus, no setResult(id, …) from inside the component.

Bootstrap without glue. ModalieurService automatically applies BootstrapDialogContainer, which wraps your component in .modal > .modal-dialog > .modal-content. You only render header, body, and footer.

Built-in confirm / alert. One-liners for the dialogs every app reimplements.

Observable-driven auto-close. Keep a modal open until a timer fires, a hub event arrives, or an async job completes — with a dedicated ModalResult.AutoClose.

When to use something else

  • Angular Material apps — use MatDialog; it is integrated with Material theming.
  • Non-Bootstrap design systems — use CDK Dialog directly with your own container.
  • A single one-off overlay — CDK alone is enough; this library shines when modals are a recurring pattern.
  • Angular < 22 — not supported. Peer dependencies require @angular/core, @angular/common, and @angular/cdk ^22.0.0.

Requirements

PackageVersionRequired
@angular/core, @angular/common^22.0.0Yes
@angular/cdk^22.0.0Yes
bootstrap^5.3.0Optional (needed for the default Bootstrap look)

Setup

1. Install

npm install ngx-modalieur @angular/cdk bootstrap

2. Add global styles (e.g. in angular.jsonprojects.[app].architect.build.options.styles):

"node_modules/bootstrap/dist/css/bootstrap.min.css",
"node_modules/@angular/cdk/overlay-prebuilt.css",
"node_modules/ngx-modalieur/styles/ngx-modalieur.css"

The library stylesheet bridges CDK overlay behavior with Bootstrap modal appearance (backdrop darkness, scrollable body layout, enter animation).

3. Register app-wide defaults (optional but recommended):

// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideModalieur } from 'ngx-modalieur';

export const appConfig: ApplicationConfig = {
  providers: [
    // Override only what you need; unset fields keep MODALIEUR_DEFAULTS.
    provideModalieur({ dismissible: false, size: 'lg' })
  ]
};

Calling provideModalieur() with no arguments registers built-in defaults. Omitting provideModalieur() entirely also works — the service falls back to MODALIEUR_DEFAULTS internally.

Quick start (60 seconds)

The fastest possible modal — no custom component needed:

import { inject } from '@angular/core';
import { ModalieurService, ModalResult } from 'ngx-modalieur';

export class MyComponent {
  private readonly modalieur = inject(ModalieurService);

  deleteItem(): void {
    this.modalieur.confirm('Delete item?', 'This cannot be undone.').subscribe(result => {
      if (result === ModalResult.Yes) {
        // user confirmed — do the work
      }
    });
  }
}

Need your own content? Define a modal component by extending ModalContent, render the Bootstrap inner sections, and close via the built-in helpers:

import { Component } from '@angular/core';
import { ModalContent } from 'ngx-modalieur';

@Component({
  template: `
    <div class="modal-header">
      <h5 class="modal-title">{{ data.title }}</h5>
      <button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
    </div>
    <div class="modal-body">{{ data.message }}</div>
    <div class="modal-footer">
      <button type="button" class="btn btn-secondary" (click)="no()">No</button>
      <button type="button" class="btn btn-primary" (click)="yes()">Yes</button>
    </div>
  `
})
export class ConfirmModalComponent extends ModalContent<{ title: string; message: string }, never> {}

Then open it and react to the outcome:

this.modalieur
  .show(ConfirmModalComponent, {
    data: { title: 'Confirm', message: 'Are you sure?' }
  })
  .subscribe(outcome => {
    if (outcome.result === ModalResult.Yes) {
      // user clicked Yes
    }
  });

Input is available inside the modal as this.data (typed from the first generic). config.data is required at the call site when the modal declares input.

Core concepts

Two layers

flowchart LR
  Caller[Caller subscribes to show]
  Service[ModalieurService]
  Shell[BootstrapDialogContainer]
  Content[Your ModalContent component]
  Caller -->|show| Service
  Service --> Shell
  Shell --> Content
  Content -->|"yes / cancel / respondWithData"| Outcome[ModalOutcome emitted once]
  Outcome --> Caller
  1. Your component (extends ModalContent) — renders .modal-header, .modal-body, .modal-footer and closes via yes(), cancel(), respondWithData(), etc.
  2. Dialog shell (BootstrapDialogContainer) — applied automatically unless unstyled: true. Wraps your component in Bootstrap's outer modal markup.

You do not extend BootstrapDialogContainer for normal modals. It is exported for advanced CDK container customization only. For fully custom layouts (viewport-filling overlays with your own CSS), pass unstyled: true and style the component yourself.

Result flow

Every close produces a ModalOutcome<T>:

interface ModalOutcome<TData = unknown> {
  result: ModalResult;
  data?: TData;
}

The observable emits once, then completes. Backdrop click and Escape map to ModalResult.Cancel when dismissible is true.

When you call show(MyModal, …), T is inferred from the component's ModalContent<TDataIn, TDataOut> declaration (TDataOut). confirm(), alert(), and messageBox() unwrap this to Observable<ModalResult> for convenience.

Config layering

Per-call config is merged in this order (later wins):

MODALIEUR_DEFAULTS  →  provideModalieur(...)  →  per-call config

Built-in defaults (MODALIEUR_DEFAULTS):

OptionDefault
backdroptrue
centeredtrue
dismissibletrue
scrollablefalse
unstyledfalse

Usage guide

Message boxes

All message-box APIs open the same built-in component (MessageBoxDialog). Pick the API by what you need back and how much wiring you want the library to do:

APIPrefer whensubscribe receivesA11y (aria-labelledby / aria-describedby)
confirm() / alert()Yes/No or OK onlyModalResultAuto-wired
messageBox({ … })Custom button setModalResultAuto-wired
show(MessageBoxDialog, …)You want ModalOutcome, or full control over ModalConfig / ariaModalOutcomeYou must set aria (see below)

confirm(), alert(), and messageBox() are thin wrappers around show(MessageBoxDialog, …). They unwrap the result to ModalResult and point the dialog at the title and body element ids for screen readers.

this.modalieur.confirm('Delete item?', 'This cannot be undone.').subscribe(result => {
  if (result === ModalResult.Yes) {
    this.deleteItem();
  }
});

this.modalieur.alert('Saved', 'Your changes were saved.', { size: 'sm' }).subscribe();

this.modalieur
  .messageBox({
    title: 'Retry?',
    message: 'Could not reach the server.',
    buttons: MessageBoxButtons.RetryCancel
  })
  .subscribe(result => {
    // ModalResult.Retry | ModalResult.Cancel
  });

confirm() and alert() do not force a size — pass { size: 'sm' } (or any ModalConfig field) when you want a compact dialog.

Available button sets (MessageBoxButtons enum): OK, OKCancel, YesNo, YesNoCancel, AbortRetryIgnore, RetryCancel.

Low-level: show(MessageBoxDialog, …)

Use this when you need the full ModalOutcome shape ({ result, data? }) for consistency with other show() calls, or when you want to pass ModalConfig without going through messageBox().

Accessibility: messageBox() / confirm() / alert() automatically set ariaLabelledBy and ariaDescribedBy to match the ids on the message-box title and body (mdlr-message-box-title, mdlr-message-box-body). If you call show(MessageBoxDialog, …) directly, pass those ids (or import the constants) so CDK Dialog can label the overlay correctly:

import { MESSAGE_BOX_BODY_ID, MESSAGE_BOX_TITLE_ID, MessageBoxDialog, MessageBoxButtons } from 'ngx-modalieur';

this.modalieur
  .show(MessageBoxDialog, {
    ariaLabelledBy: MESSAGE_BOX_TITLE_ID,
    ariaDescribedBy: MESSAGE_BOX_BODY_ID,
    data: { title: 'Delete?', message: 'Cannot be undone.', buttons: MessageBoxButtons.YesNo }
  })
  .subscribe(outcome => {
    // outcome.result === ModalResult.Yes | ModalResult.No | ModalResult.Cancel
  });

Custom markup (content projection)

@Component({
  imports: [MessageBoxDialog],
  template: `
    <mdlr-message-box>
      <div mbHeader>Custom header</div>
      <div mbBody>Custom body</div>
      <div mbFooter class="d-flex gap-2">
        <button type="button" class="btn btn-secondary" (click)="cancel()">Dismiss</button>
        <button type="button" class="btn btn-primary" (click)="ok()">Got it</button>
      </div>
    </mdlr-message-box>
  `
})
export class MyMessageBox extends ModalContent {}

Custom modal components

Extend ModalContent and use the protected close helpers:

MethodModalResult
yes(data?)Yes
no(data?)No
ok(data?)Ok
cancel(data?)Cancel
abort(data?)Abort
retry(data?)Retry
ignore(data?)Ignore
respondWithData(data)Data
close(result?, data?)any

The modal component does not inject a global modal service. Closing the dialog is emitting the result.

Typing modals

Every modal extends ModalContent<TDataIn = void, TDataOut = never>. The component is the single source of truth — show() infers input and output types from it.

ShapeDeclarationconfig.dataoutcome.data
No input, no outputModalContent (defaults)optionalnever (result only)
Input onlyModalContent<In, never>requirednever
Output onlyModalContent<void, Out>optionaltyped (optional to return)
BothModalContent<In, Out>requiredtyped (optional to return)
  • void — no meaningful input; this.data exists but is unusable.
  • never — cannot return output data; respondWithData is uncallable.
  • A concrete TDataOut — returning data is optional: close() / yes() accept data?, so the same modal can close with or without a payload. respondWithData is the explicit always-with-data path.

Inside the modal, input is available as this.data (injected by the base class). Do not inject MODAL_DATA manually. ModalDataIn<C> and ModalDataOut<C> extract types from a component class for advanced/generic callers.

Passing data in and out

  • Input — declare TDataIn on ModalContent; pass via config.data (required when TDataIn is not void); read as this.data inside the modal.
  • Output — declare TDataOut on ModalContent; returned as outcome.data when a close helper includes a payload.
class EditModal extends ModalContent<{ id: number }, { saved: boolean }> {
  protected save = () => this.respondWithData({ saved: true });
}

this.modalieur.show(EditModal, { data: { id: 7 } }).subscribe(outcome => {
  if (outcome.result === ModalResult.Data && outcome.data) {
    console.log(outcome.data.saved); // true after save()
  }
});

Configuration

All options live on ModalConfig and can be set app-wide (provideModalieur) or per call:

OptionDescriptionDefault
dataInjected as this.data; required at call site when TDataIn is not void
size'sm' | 'md' | 'lg' | 'xl' | 'fullscreen'Bootstrap medium (md adds no extra class)
centered.modal-dialog-centeredtrue
scrollable.modal-dialog-scrollablefalse
dismissibleBackdrop click / Escape closes → Canceltrue
backdropRender CDK backdroptrue
unstyledSkip Bootstrap shell; component owns layoutfalse
ariaLabelCDK ariaLabel
ariaLabelledByCDK ariaLabelledBy
ariaDescribedByCDK ariaDescribedBy

Try every combination live in the playground.

Non-dismissible modals with no backdrop (common in kiosk / operator UIs):

provideModalieur({ dismissible: false, backdrop: false, centered: true });

Scrollable body with long content:

this.modalieur.show(ConfirmModalComponent, {
  scrollable: true,
  data: { title: 'Terms', message: longText }
});

Reactive patterns

Opening a modal returns an Observable — compose with the rest of your RxJS pipelines:

import { filter, switchMap } from 'rxjs/operators';

this.modalieur
  .confirm('Delete item?', 'This cannot be undone.')
  .pipe(
    filter(result => result === ModalResult.Yes),
    switchMap(() => this.api.deleteItem(id))
  )
  .subscribe();

Compared to imperative modal stacks many codebases inherit:

// Before: ref + global bus + setResult inside the component
const ref = modalService.showAndReturnRef(MyModal);
modalService.getModalResult(ref).subscribe(/* ... */);
// inside modal: modalService.setResult(ref.id, ResultType.Yes);

// After: one subscribe, close helpers inside the component
this.modalieur.show(MyModal, { data }).subscribe(outcome => {
  if (outcome.result === ModalResult.Yes) this.save();
});
APIEmitsWhen
show(…)ModalOutcome<T>User closes or dismisses
confirm() / alert() / messageBox()ModalResultButton click or dismiss
showUntil(…) / showUntilCondition(…)ModalOutcome with AutoCloseUser action or observable fires
showAndReturnRef(…).closed$ModalOutcome<T>Same as show, plus you hold ModalRef

Auto-close with observables

Keep a modal open until an external signal fires, then close with ModalResult.AutoClose.

showUntilshowUntilCondition
Closes whenFirst emission (any value)First truthy emission
false, 0, ''ClosesIgnored — modal stays open
Typical useTimers, one-shot eventsReadiness signals (loaded$, saveComplete$)
import { timer } from 'rxjs';
import { filter, map, take } from 'rxjs/operators';

// Close after 4 seconds regardless of emission value
this.modalieur
  .showUntil(WaitingModalComponent, timer(4000).pipe(map(() => false)), {
    data: { title: 'Loading…', message: 'Please wait.' }
  })
  .subscribe(outcome => {
    // outcome.result === ModalResult.AutoClose
  });

// Close when status becomes 'done'
const ready$ = this.pollStatus().pipe(
  map(s => s === 'done'),
  filter(Boolean),
  take(1)
);
this.modalieur
  .showUntilCondition(WaitingModalComponent, ready$, {
    data: { title: 'Loading…', message: 'Please wait.' }
  })
  .subscribe();

The user can still close early via buttons or dismissal — AutoClose only applies when the observable triggers the close.

Programmatic control

When you need to close from outside the component (e.g. after an async save), use showAndReturnRef. The modal must declare an output type if you pass a payload to close(); returning data is optional — omit the second argument when you only need the result.

// SpinnerModal extends ModalContent<void, { savedId: number }>
const ref = this.modalieur.showAndReturnRef(SpinnerModal, { dismissible: false });

ref.closed$.subscribe(outcome => this.onSaveComplete(outcome));

await this.save();
ref.close(ModalResult.Ok, { savedId: 42 }); // payload optional when TDataOut is concrete

ModalRef is also injectable inside the modal component (via ModalContent's internal wiring).

Custom layouts

Bootstrap fullscreen — uses the built-in shell:

this.modalieur.show(ConfirmModalComponent, { size: 'fullscreen', data });

Fully custom overlay — skip the Bootstrap shell:

this.modalieur.show(MyOverlayComponent, { unstyled: true, data });

Your component owns the entire layout (positioning, z-index, animations). CDK still provides overlay, focus trap, and backdrop.

Lazy loading

show() accepts a component class (Type<C>), not a route-style loadComponent loader. To lazy-load a modal, dynamically import it first, then pass the resolved class to show().

Async/await:

async openLazyModal(): Promise<void> {
  const { LazyLoadModalComponent } = await import('./modals/lazy-load-modal.component');

  this.modalieur
    .show(LazyLoadModalComponent)
    .subscribe((outcome) => {
      // outcome.result: ModalResult
    });
}

RxJS:

import { from, switchMap } from 'rxjs';

from(import('./modals/lazy-load-modal.component'))
  .pipe(switchMap(({ LazyLoadModalComponent }) => this.modalieur.show(LazyLoadModalComponent)))
  .subscribe(outcome => {
    // outcome.result: ModalResult
  });

Avoid eager imports. A top-level import { LazyLoadModalComponent } in code loaded at startup pulls the modal into the initial bundle. Use dynamic import() only where you open the modal.

For simple yes/no dialogs, confirm() and messageBox() avoid a custom component entirely — no separate chunk needed.

API reference

Public exports

Everything in public-api.ts is part of the stable API: ModalieurService, ModalContent, ModalRef, ModalConfig, ModalOutcome, ModalResult, ModalSize, ModalDataIn, ModalDataOut, MODAL_DATA, MODALIEUR_CONFIG, MODALIEUR_DEFAULTS, provideModalieur, MessageBoxDialog, MessageBoxButtons, MessageBoxOptions, MESSAGE_BOX_TITLE_ID, MESSAGE_BOX_BODY_ID, BootstrapDialogContainer.

ModalieurService

MethodReturnsDescription
show(component, config?)Observable<ModalOutcome<T>>Opens a modal; emits when it closes. config required (with data) when component has input.
showUntil(component, until$, config?)Observable<ModalOutcome<T>>Auto-closes on first until$ emission → AutoClose.
showUntilCondition(component, condition$, config?)Observable<ModalOutcome<T>>Auto-closes on first truthy emission → AutoClose.
showAndReturnRef(component, config?)ModalRef<T>Opens a modal; returns a ref for programmatic control.
messageBox(options, config?)Observable<ModalResult>Config-driven MessageBoxDialog.
confirm(title, message?, config?)Observable<ModalResult>Yes / No message box.
alert(title, message?, config?)Observable<ModalResult>Single OK message box.

ModalRef

MemberDescription
idCDK dialog id
closed$Observable<ModalOutcome<T>> — emits when the modal closes
close(result?, data?)Programmatic close; default result is Undefined

ModalResult

ValueWhen
UndefinedProgrammatic close() with no result
DatarespondWithData()
Yes, No, Ok, CancelButton helpers
Abort, Retry, IgnoreMessage-box helpers
AutoCloseshowUntil / showUntilCondition auto-close
CancelUser dismissal (backdrop / Escape) when dismissible

Testing

Provide a fake CDK Dialog and assert on closed emissions. See modalieur.service.spec.ts for the full pattern:

import { Dialog } from '@angular/cdk/dialog';
import { TestBed } from '@angular/core/testing';
import { ModalieurService } from 'ngx-modalieur';

TestBed.configureTestingModule({
  providers: [ModalieurService, { provide: Dialog, useValue: fakeDialog }]
});

License

MIT