ngx-modalieur
Reactive Bootstrap modals for Angular — a thin layer on CDK Dialog.
npm install ngx-modalieur @angular/cdk bootstrap
Live demo — interactive examples in the browser.
Table of contents
- ngx-modalieur
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.
Why use it?
| Raw CDK Dialog | ngx-modalieur | |
|---|---|---|
| Styling | Bring your own container and CSS | Bootstrap .modal-dialog shell + bridging styles |
| Close result | dialogRef.close(value) — shape is yours |
Standardized { result: ModalResult; data? } |
| Message boxes | Build yourself | confirm(), alert(), messageBox() |
| App-wide defaults | DIY injection token | provideModalieur({ … }) |
| Auto-close on streams | Wire takeUntil + close() yourself |
showUntil() / 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
| Package | Version | Required |
|---|---|---|
@angular/core, @angular/common |
^22.0.0 |
Yes |
@angular/cdk |
^22.0.0 |
Yes |
bootstrap |
^5.3.0 |
Optional (needed for the default Bootstrap look) |
Setup
1. Install
npm install ngx-modalieur @angular/cdk bootstrap
2. Add global styles (e.g. in angular.json → projects.[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
1. Define a modal component — extend ModalContent, render Bootstrap inner sections, close via helpers:
import { Component } from '@angular/core';
import { ModalContent } from 'ngx-modalieur';
@Component({
standalone: true,
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> {}
Input is available as this.data (typed from the first generic). config.data is required at the call site when the modal declares input.
2. Open it and react to the outcome:
import { inject } from '@angular/core';
import { ModalieurService, ModalResult } from 'ngx-modalieur';
// In a component or service:
private readonly modalieur = inject(ModalieurService);
openConfirm(): void {
this.modalieur
.show(ConfirmModalComponent, {
data: { title: 'Confirm', message: 'Are you sure?' }
})
.subscribe(outcome => {
if (outcome.result === ModalResult.Yes) {
// user clicked Yes
}
});
}
Or skip the custom component entirely:
this.modalieur.confirm('Delete item?', 'This cannot be undone.').subscribe(result => {
if (result === ModalResult.Yes) {
this.deleteItem();
}
});
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
- Your component (
extends ModalContent) — renders.modal-header,.modal-body,.modal-footerand closes viayes(),cancel(),respondWithData(), etc. - Dialog shell (
BootstrapDialogContainer) — applied automatically unlessunstyled: 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):
| Option | Default |
|---|---|
backdrop |
true |
centered |
true |
dismissible |
true |
scrollable |
false |
unstyled |
false |
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:
| API | Prefer when | subscribe receives |
A11y (aria-labelledby / aria-describedby) |
|---|---|---|---|
confirm() / alert() |
Yes/No or OK only | ModalResult |
Auto-wired |
messageBox({ … }) |
Custom button set | ModalResult |
Auto-wired |
show(MessageBoxDialog, …) |
You want ModalOutcome, or full control over ModalConfig / aria |
ModalOutcome |
You must set aria (see below) |
Shorthand (recommended for most cases)
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().
These two calls open the same dialog; only the return type and a11y wiring differ:
// Shorthand — emits ModalResult.Yes | ModalResult.No; aria wired for you.
this.modalieur.confirm('Delete?', 'Cannot be undone.').subscribe(result => {
/* … */
});
// Equivalent low-level — emits ModalOutcome; you handle aria yourself.
this.modalieur
.show(MessageBoxDialog, {
data: { title: 'Delete?', message: 'Cannot be undone.', buttons: MessageBoxButtons.YesNo }
})
.subscribe(outcome => {
// outcome.result === ModalResult.Yes | ModalResult.No | ModalResult.Cancel
});
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 => {
/* … */
});
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:
| Method | ModalResult |
|---|---|
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.
| Shape | Declaration | config.data |
outcome.data |
|---|---|---|---|
| No input, no output | ModalContent (defaults) |
optional | never (result only) |
| Input only | ModalContent<In, never> |
required | never |
| Output only | ModalContent<void, Out> |
optional | typed (optional to return) |
| Both | ModalContent<In, Out> |
required | typed (optional to return) |
void— no meaningful input;this.dataexists but is unusable.never— cannot return output data;respondWithDatais uncallable.- A concrete
TDataOut— returning data is optional:close()/yes()acceptdata?, so the same modal can close with or without a payload.respondWithDatais 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
TDataInonModalContent; pass viaconfig.data(required whenTDataInis notvoid); read asthis.datainside the modal. - Output — declare
TDataOutonModalContent; returned asoutcome.datawhen 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:
| Option | Description | Default |
|---|---|---|
data |
Injected 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-centered |
true |
scrollable |
.modal-dialog-scrollable |
false |
dismissible |
Backdrop click / Escape closes → Cancel |
true |
backdrop |
Render CDK backdrop | true |
unstyled |
Skip Bootstrap shell; component owns layout | false |
ariaLabel |
CDK ariaLabel |
— |
ariaLabelledBy |
CDK ariaLabelledBy |
— |
ariaDescribedBy |
CDK ariaDescribedBy |
— |
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();
});
| API | Emits | When |
|---|---|---|
show(…) |
ModalOutcome<T> |
User closes or dismisses |
confirm() / alert() / messageBox() |
ModalResult |
Button click or dismiss |
showUntil(…) / showUntilCondition(…) |
ModalOutcome with AutoClose |
User 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.
showUntil |
showUntilCondition |
|
|---|---|---|
| Closes when | First emission (any value) | First truthy emission |
false, 0, '' |
Closes | Ignored — modal stays open |
| Typical use | Timers, one-shot events | Readiness 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.
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
| Method | Returns | Description |
|---|---|---|
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. See Auto-close. |
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
| Member | Description |
|---|---|
id |
CDK dialog id |
closed$ |
Observable<ModalOutcome<T>> — emits when the modal closes |
close(result?, data?) |
Programmatic close; default result is Undefined |
ModalResult
| Value | When |
|---|---|
Undefined |
Programmatic close() with no result |
Data |
respondWithData() |
Yes, No, Ok, Cancel |
Button helpers |
Abort, Retry, Ignore |
Message-box helpers |
AutoClose |
showUntil / showUntilCondition auto-close |
Cancel |
User 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