A reusable Angular modal component should separate the dialog shell from the content placed inside it. The shell owns visibility, backdrop behavior, size, and close events; the parent supplies the header, body, and footer. This keeps page-specific forms and actions out of the shared component.
This article preserves the Angular 18.2 implementation and demonstration published on December 4, 2024, while explaining its real production boundary. The historical code worked for the original project, but it has not been rebuilt or tested against the current Angular release. The accessibility gaps documented below must be addressed before treating it as a production-ready dialog.
Table of Contents
Define the modal component boundary
The component exposes three inputs and one output. visible controls whether the modal exists in the DOM, size selects a width class, and clickBackdropToClose decides whether a click outside the dialog closes it. The closed event lets the parent reset its own state.
- The modal owns: the backdrop, dialog frame, size class, animation, and close signal.
- The parent owns: the business data, form state, validation, save operation, and the decision to open the modal.
- Projected content owns: the visible heading, body controls, and action buttons for one use case.
This boundary is useful when several screens need the same visual shell but not the same form. It also avoids a shared component that knows about courses, employees, or any other domain object.
Build the Angular modal component class
The original repository uses Angular 18.2 and the legacy @angular/animations trigger API. The following class is preserved from that working demonstration.
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { trigger, style, transition, animate } from '@angular/animations';
@Component({
selector: 'dnc-modal',
templateUrl: './dnc-modal.component.html',
styleUrls: ['./dnc-modal.component.css'],
animations: [
trigger('fade', [
transition(':enter', [
style({ opacity: 0 }),
animate('300ms ease-in', style({ opacity: 1 })),
]),
transition(':leave', [
animate('300ms ease-out', style({ opacity: 0 })),
]),
]),
],
})
export class DncModalComponent {
@Input() visible = false;
@Input() size: 'small' | 'normal' | 'large' = 'normal';
@Input() clickBackdropToClose = true;
@Output() closed = new EventEmitter<void>();
show(): void {
this.visible = true;
}
hide(): void {
this.visible = false;
this.closed.emit();
}
onBackdropClick(event: MouseEvent): void {
if (this.clickBackdropToClose && event.target === event.currentTarget) {
this.hide();
}
}
getModalSizeClass(): string {
switch (this.size) {
case 'small': return 'dnc-modal-sm';
case 'large': return 'dnc-modal-lg';
default: return 'dnc-modal-md';
}
}
}
The equality check between event.target and event.currentTarget matters. A click on the backdrop has the same target and current target, while a click on a child inside the dialog does not. Without that guard, interacting with a form control could close the modal through event bubbling.
The parent should still be the source of truth for visibility. Calling hide() updates the child immediately and emits closed, but the parent must also set its bound state to false. In a new implementation, a two-way model API or an explicit visibleChange event can make that contract clearer.
Project the header, body, and footer
Angular content projection keeps the modal generic. Three <ng-content> placeholders select elements marked for the header, body, and footer. The parent can then supply any appropriate markup without adding domain inputs to the modal class.
<div
*ngIf="visible"
class="dnc-modal-backdrop"
@fade
(click)="onBackdropClick($event)">
<div
class="dnc-modal-dialog"
[ngClass]="getModalSizeClass()"
role="dialog"
aria-modal="true">
<div class="dnc-modal-content">
<div class="dnc-modal-header">
<ng-content select="[modal-header]"></ng-content>
<button
type="button"
class="btn-close"
aria-label="Close"
(click)="hide()"></button>
</div>
<div class="dnc-modal-body">
<ng-content select="[modal-body]"></ng-content>
</div>
<div class="dnc-modal-footer">
<ng-content select="[modal-footer]"></ng-content>
</div>
</div>
</div>
</div>
The selectors match attributes on projected elements; they are not extra Angular components. Content projection is resolved from the component template, so do not conditionally add or remove the <ng-content> placeholders themselves. Control whether the whole modal is rendered instead.
Style the backdrop and modal sizes
The backdrop fills the viewport and centers the dialog. The three size classes cap the dialog width, while the content panel supplies the background, border radius, and shadow.
.dnc-modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.6);
z-index: 1050;
}
.dnc-modal-dialog {
max-width: 100%;
margin: 0;
}
.dnc-modal-sm { max-width: 300px; }
.dnc-modal-md { max-width: 500px; }
.dnc-modal-lg { max-width: 800px; }
.dnc-modal-content {
background: #fff;
border-radius: 0.5rem;
overflow: hidden;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);
}
.dnc-modal-header,
.dnc-modal-footer {
display: flex;
align-items: center;
padding: 1rem 1.5rem;
background-color: #f8f9fa;
}
.dnc-modal-header {
justify-content: space-between;
border-bottom: 1px solid #dee2e6;
}
.dnc-modal-body {
padding: 1.5rem;
}
.dnc-modal-footer {
justify-content: flex-end;
border-top: 1px solid #dee2e6;
}
The historical stylesheet needs extra constraints for narrow or short viewports. A production version should add a viewport-relative width, a maximum height, internal scrolling, and safe spacing from the screen edge. It should also prevent the page behind the modal from scrolling while the dialog is open.
.dnc-modal-dialog {
width: min(90vw, 500px);
max-height: 90vh;
}
.dnc-modal-content {
max-height: 90vh;
display: flex;
flex-direction: column;
}
.dnc-modal-body {
overflow-y: auto;
}
This addition is guidance, not a claim that the original video or repository included it. Choose one width system rather than combining conflicting fixed and fluid values.
Use the modal from a parent component
The parent owns the open flag and handles the close notification. The same modal shell can then host different projected content without duplicating the backdrop and layout.
<button type="button" (click)="courseModalOpen = true">
Add course
</button>
<dnc-modal
[visible]="courseModalOpen"
size="normal"
[clickBackdropToClose]="false"
(closed)="courseModalOpen = false">
<h2 modal-header id="course-modal-title">Add course</h2>
<form modal-body id="course-form" (ngSubmit)="saveCourse()">
<label for="course-name">Course name</label>
<input id="course-name" name="courseName" [(ngModel)]="courseName" />
</form>
<div modal-footer>
<button type="button" (click)="courseModalOpen = false">
Cancel
</button>
<button type="submit" form="course-form">
Save
</button>
</div>
</dnc-modal>
Disabling backdrop close is safer when the dialog contains unsaved form data. The example also associates the Save button with the projected form through its form attribute, so the footer can remain outside the form element.
If the form needs an autocomplete field, the Angular autocomplete component guide explains a reusable form-control boundary. For a server-rendered alternative, compare the lifecycle and focus requirements with the reusable Blazor modal component.
Fix the accessibility limits before production
role="dialog" and aria-modal="true" do not make a modal accessible by themselves. The original component omits several behaviors required by the WAI-ARIA modal dialog pattern.
- Move focus inside on open. Put initial focus on the heading, first meaningful control, or another deliberate target.
- Trap keyboard focus. Tab and Shift+Tab must cycle within the open dialog.
- Support Escape. Escape should close the dialog unless the workflow has a documented reason to block it.
- Restore focus. After close, return focus to the control that opened the dialog or to another logical element.
- Give the dialog an accessible name. Connect the dialog to a visible heading with
aria-labelledby, or provide an accuratearia-label. - Make the background inert. Users must not interact with content outside a modal dialog while it is open.
The projected heading in the parent example has id="course-modal-title", but the historical modal template does not expose an input that assigns that value to aria-labelledby. Add that contract before shipping. A robust implementation should also handle nested overlays, multiple modal requests, scroll locking, and cleanup when the component is destroyed.
Do not mark a non-modal popover with aria-modal="true". That attribute tells assistive technology that the rest of the page is unavailable, so the application behavior must match the promise.
Plan a migration to current Angular
The repository records Angular 18.2. As of September 2, 2026, Angular 18 is outside the supported release window, while newer Angular lines are active or in long-term support. That does not erase the historical result, but it changes how the sample should be used.
- Start from the repository when you need to understand the original design, not as proof of compatibility with a current workspace.
- Run Angular’s supported update path one major version at a time and review each migration.
- Replace obsolete animation patterns according to the current Angular animation guidance rather than mechanically copying the Angular 18 setup.
- Retest projection, focus behavior, backdrop clicks, keyboard interaction, forms, and viewport constraints after migration.
- Run the test in the same rendering mode used by the real application. Server-side rendering and hydration behavior were not tested for this refresh.
A new application may also be better served by the browser’s native <dialog> element or an established Angular overlay library. The custom component remains useful when its small API is intentional and the team is prepared to own focus management, stacking, scrolling, and assistive-technology testing.
Verify the historical implementation
The repository is the evidence for the original Angular 18.2 implementation. To reproduce it, use a Node.js version compatible with that Angular workspace, install the locked dependencies, and start the development server. The commands below are verification instructions; they were not rerun as part of this editorial refresh.
git clone https://github.com/dotnetcodercom/reusable-angular-modal-component.git
cd reusable-angular-modal-component/dnc-modal
npm ci
npm start
- Open and close each modal from the control that launches it.
- Verify small, normal, and large widths at desktop and narrow viewport sizes.
- Confirm that a backdrop click closes only when
clickBackdropToCloseis enabled. - Confirm that clicks inside the dialog never trigger the backdrop handler.
- Check that the
closedevent resets parent state. - Verify projected header, body, and footer content for each example.
- Test Tab, Shift+Tab, Escape, initial focus, and restored focus. Expect the historical component to expose the accessibility gaps listed above until they are implemented.
- Inspect the browser console and run the workspace tests and production build before using migrated code.
Watch the original video and inspect the source
The video below is the original demonstration recorded for the 2024 article. It shows the historical result and workflow; it is not evidence that the repository was retested against a later Angular release.
The practical design is still sound: keep reusable shell behavior in one component and keep business state in the parent. The production work lies in the details—especially focus, keyboard handling, scroll control, responsive sizing, and a deliberate migration from Angular 18.
References
- Angular documentation: content projection with ng-content
- Angular documentation: release support policy and schedule
- Angular documentation: current animation guidance
- Angular documentation: migrating legacy animations to native CSS
- WAI-ARIA Authoring Practices: modal dialog pattern
Historical demo: review the original Angular 18.2 source code on GitHub.
Found this useful? Support more practical developer content.