Angular Components in Practice: A Reusable Modal + Focus Trap (No Libraries)
Modals look simple until you ship them: keyboard users can’t escape, focus jumps behind the overlay, Esc doesn’t close reliably, and clicking the backdrop sometimes triggers weird side effects. In this hands-on guide, you’ll build a reusable Angular modal component with a basic focus trap, accessible semantics, and clean APIs—without relying on a UI library.
You’ll end up with:
- A
<app-modal>component you can reuse anywhere - Keyboard support:
Escto close,Tab/Shift+Tabtrapped inside - Backdrops, animations-ready structure, and body scroll lock
- Accessible attributes (
role="dialog",aria-modal, labeling)
1) Create the component
Generate a modal component:
ng generate component shared/modal
We’ll implement it as a presentational component that:
- Receives an
openboolean - Emits
closedevents - Projects content for header/body/footer
2) Modal template (HTML)
Create modal.component.html:
<!-- Only render when open to keep the DOM clean --> <ng-container *ngIf="open"> <div class="backdrop" (click)="onBackdropClick($event)"></div> <div class="modal" role="dialog" aria-modal="true" [attr.aria-labelledby]="titleId" [attr.aria-describedby]="descriptionId" (click)="$event.stopPropagation()" > <div class="modal__header"> <ng-content select="[modalTitle]"></ng-content> <button type="button" class="icon-button" (click)="requestClose('button')" aria-label="Close dialog" > ✕ </button> </div> <div class="modal__body"> <ng-content select="[modalBody]"></ng-content> </div> <div class="modal__footer"> <ng-content select="[modalFooter]"></ng-content> </div> </div> </ng-container>
We used content projection with attribute selectors:
[modalTitle]for the title region[modalBody]for the body content[modalFooter]for actions
3) Modal styles (CSS)
Create modal.component.css (basic, minimal):
.backdrop { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.55); } .modal { position: fixed; top: 50%; left: 50%; width: min(560px, calc(100vw - 2rem)); max-height: calc(100vh - 2rem); transform: translate(-50%, -50%); background: #fff; border-radius: 12px; overflow: auto; box-shadow: 0 10px 30px rgba(0,0,0,0.25); padding: 1rem; } .modal__header, .modal__footer { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; } .modal__header { border-bottom: 1px solid #eee; padding-bottom: 0.75rem; margin-bottom: 0.75rem; } .modal__footer { border-top: 1px solid #eee; padding-top: 0.75rem; margin-top: 0.75rem; justify-content: flex-end; } .icon-button { border: none; background: transparent; font-size: 1.1rem; cursor: pointer; padding: 0.25rem 0.5rem; }
Note: real apps often add enter/exit animations. This structure supports it easily later (e.g., add CSS transitions and keep it mounted briefly on close).
4) The component logic (TypeScript): close handling + focus trap
Create modal.component.ts with focus management. This is the “technical meat”:
import { Component, ElementRef, EventEmitter, HostListener, Input, OnChanges, OnDestroy, Output, SimpleChanges, ViewChild, } from '@angular/core'; type CloseReason = 'esc' | 'backdrop' | 'button' | 'api'; @Component({ selector: 'app-modal', templateUrl: './modal.component.html', styleUrls: ['./modal.component.css'], }) export class ModalComponent implements OnChanges, OnDestroy { @Input() open = false; // IDs used for aria-labelledby / aria-describedby @Input() titleId = 'modal-title'; @Input() descriptionId = 'modal-description'; @Input() closeOnBackdrop = true; @Input() closeOnEsc = true; @Output() closed = new EventEmitter<CloseReason>(); private previouslyFocused: HTMLElement | null = null; constructor(private host: ElementRef<HTMLElement>) {} ngOnChanges(changes: SimpleChanges): void { if (changes['open']) { if (this.open) { this.onOpen(); } else { this.onClose('api'); } } } ngOnDestroy(): void { // If the component is destroyed while open, restore scroll/focus safely. if (this.open) { this.unlockBodyScroll(); this.restoreFocus(); } } requestClose(reason: CloseReason): void { // Parent controls open state; we just emit the intent. this.closed.emit(reason); } onBackdropClick(event: MouseEvent): void { // Backdrop is separate div; still guard with closeOnBackdrop. if (!this.closeOnBackdrop) return; this.requestClose('backdrop'); } private onOpen(): void { this.previouslyFocused = document.activeElement as HTMLElement | null; this.lockBodyScroll(); // Wait a tick for projected content to render. queueMicrotask(() => { this.focusFirstElementOrModal(); }); } private onClose(_reason: CloseReason): void { this.unlockBodyScroll(); this.restoreFocus(); } private lockBodyScroll(): void { document.body.style.overflow = 'hidden'; } private unlockBodyScroll(): void { document.body.style.overflow = ''; } private restoreFocus(): void { if (this.previouslyFocused && typeof this.previouslyFocused.focus === 'function') { this.previouslyFocused.focus(); } this.previouslyFocused = null; } private getModalElement(): HTMLElement | null { // The modal container is inside this component's host. return this.host.nativeElement.querySelector('.modal'); } private getFocusableElements(): HTMLElement[] { const modal = this.getModalElement(); if (!modal) return []; const selector = [ 'a[href]', 'button:not([disabled])', 'textarea:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', '[tabindex]:not([tabindex="-1"])', ].join(','); return Array.from(modal.querySelectorAll<HTMLElement>(selector)) // Hide elements that can't actually be interacted with .filter(el => !el.hasAttribute('disabled') && el.tabIndex >= 0); } private focusFirstElementOrModal(): void { const modal = this.getModalElement(); if (!modal) return; const focusables = this.getFocusableElements(); if (focusables.length > 0) { focusables[0].focus(); } else { // Make modal focusable as a fallback modal.setAttribute('tabindex', '-1'); modal.focus(); } } @HostListener('document:keydown', ['$event']) onKeydown(event: KeyboardEvent): void { if (!this.open) return; if (event.key === 'Escape' && this.closeOnEsc) { event.preventDefault(); this.requestClose('esc'); return; } if (event.key === 'Tab') { this.trapTabKey(event); } } private trapTabKey(event: KeyboardEvent): void { const focusables = this.getFocusableElements(); if (focusables.length === 0) { event.preventDefault(); this.focusFirstElementOrModal(); return; } const first = focusables[0]; const last = focusables[focusables.length - 1]; const active = document.activeElement as HTMLElement | null; if (!active) return; const goingBackwards = event.shiftKey; if (!goingBackwards && active === last) { event.preventDefault(); first.focus(); } else if (goingBackwards && active === first) { event.preventDefault(); last.focus(); } } }
Key ideas to notice:
- We save the previously focused element and restore it on close (better UX).
- We trap
Tabby cycling focus between the first and last focusable element. - We lock body scroll while the modal is open.
- We emit
closedand let the parent decide how to updateopen.
5) Use the modal from a page
Here’s a simple parent component that opens/closes the modal and responds to closed reasons.
profile.component.html:
<button type="button" (click)="isOpen = true">Edit profile</button> <app-modal [open]="isOpen" titleId="edit-profile-title" descriptionId="edit-profile-description" (closed)="handleClose($event)" > <h3 id="edit-profile-title" modalTitle>Edit profile</h3> <div id="edit-profile-description" modalBody> <p>Update your display name and bio.</p> <label> Display name <input type="text" [(ngModel)]="displayName" /> </label> <label> Bio <textarea rows="3" [(ngModel)]="bio"></textarea> </label> </div> <div modalFooter> <button type="button" (click)="isOpen = false">Cancel</button> <button type="button" (click)="save()">Save</button> </div> </app-modal>
profile.component.ts:
import { Component } from '@angular/core'; @Component({ selector: 'app-profile', templateUrl: './profile.component.html', }) export class ProfileComponent { isOpen = false; displayName = 'Ada Lovelace'; bio = 'Writes code and poetry.'; handleClose(reason: 'esc' | 'backdrop' | 'button' | 'api'): void { // Decide what to do based on reason: // e.g. confirm before discard on backdrop, allow esc, etc. this.isOpen = false; // Optional: console.log('Closed because:', reason); } save(): void { // Persist changes, then close // ... call API ... this.isOpen = false; } }
If you’re using ngModel, remember to import FormsModule in your module (or in standalone components, add it to imports).
6) Common pitfalls (and how this implementation avoids them)
-
“Tab escapes behind the modal”: We intercept
Taband cycle focus between the first and last focusable elements. -
“Focus starts behind the overlay”: On open we focus the first focusable element (or the modal itself as a fallback).
-
“When closing, focus is lost”: We restore focus to whatever was active before the modal opened.
-
“Page scrolls behind the dialog”: We lock body scroll while open.
-
“Backdrop clicks close the modal unexpectedly”: Backdrop close is controlled by
[closeOnBackdrop]so you can disable it for destructive forms.
7) Small upgrades you can add next
Once this is working, here are realistic improvements junior/mid devs can implement:
-
Animations: keep the modal mounted during exit transitions (e.g.,
isVisible+setTimeout, or Angular animations). -
Click-outside guard: close only if the user clicked directly on the backdrop, not if they started a drag inside the modal.
-
Multiple modals: handle stacking (z-index, focus trap per topmost modal).
-
Auto-focus an element: accept an input like
[initialFocusSelector]="'#name'". -
Prevent close on dirty forms: if the form is dirty, open a confirm dialog before closing.
Wrap-up
You now have a practical, dependency-free Angular modal component that behaves well for mouse and keyboard users, is accessible by default, and is clean to integrate. This is the kind of component that pays off quickly in real projects—especially when you build it once and reuse it everywhere.
Leave a Reply