ngx-modalieur
August 11, 2026 · View on GitHub
Reactive Bootstrap modals for Angular — a thin, typed layer on CDK Dialog.
npm install ngx-modalieur @angular/cdk bootstrap
Open a modal,
subscribeto the result. No modal ids, no global result bus, no boilerplate.
Try the live demo — interactive examples and a config playground in your browser.
Related projects
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-modalieurfor interactions that require a response, andngx-toaster-nextfor non-blocking feedback. Contributions, bug reports, and ideas are welcome in both projects.
Table of contents
- What is this?
- Why use it?
- When to use something else
- Requirements
- Setup
- Quick start (60 seconds)
- Core concepts
- Usage guide
- API reference
- Testing
- License
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 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 (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
- 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().
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:
| 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 | — |
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();
});
| 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.
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
| 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. |
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