Modal Dialog

Build a flexible modal dialog component with backdrop, animations, and focus management

Learn to build a production-ready modal dialog with proper focus management, backdrop click handling, animations, and accessibility. Perfect for confirmations, forms, and detailed content displays.

What We're Building

A modal dialog that supports:

  • Open/close with smooth animations

  • Backdrop click to close (optional)

  • Escape key to close

  • Focus trap (keyboard focus stays within modal)

  • Return focus to trigger when closed

  • Accessible with ARIA attributes

  • Portal rendering (renders outside parent context)

  • Scrollable content

Component Code

import { bindable, IEventAggregator } from 'aurelia';
import { resolve } from '@aurelia/kernel';
import { queueTask } from '@aurelia/runtime';
import { IPlatform } from '@aurelia/runtime-html';

export class ModalDialog {
  @bindable open = false;
  @bindable closeOnBackdropClick = true;
  @bindable closeOnEscape = true;
  @bindable size: 'small' | 'medium' | 'large' | 'full' = 'medium';

  private platform = resolve(IPlatform);
  private element?: HTMLElement;
  private modalElement?: HTMLElement;
  private previousActiveElement?: HTMLElement;
  private focusableElements: HTMLElement[] = [];

  openChanged(newValue: boolean) {
    if (newValue) {
      this.onOpen();
    } else {
      this.onClose();
    }
  }

  attaching(initiator: HTMLElement) {
    this.element = initiator;
    this.modalElement = this.element.querySelector('[data-modal]') as HTMLElement;
  }

  detaching() {
    // Clean up if modal is still open
    if (this.open) {
      this.cleanupModal();
    }
  }

  closeModal() {
    this.open = false;
  }

  handleBackdropClick(event: MouseEvent) {
    // Only close if clicking the backdrop itself, not content inside
    if (this.closeOnBackdropClick && event.target === event.currentTarget) {
      this.closeModal();
    }
  }

  handleKeyDown(event: KeyboardEvent) {
    if (event.key === 'Escape' && this.closeOnEscape) {
      event.preventDefault();
      this.closeModal();
      return;
    }

    // Tab key focus trap
    if (event.key === 'Tab') {
      this.handleTabKey(event);
    }
  }

  private onOpen() {
    // Store currently focused element to return focus later
    this.previousActiveElement = document.activeElement as HTMLElement;

    // Prevent body scroll
    document.body.style.overflow = 'hidden';

    // Wait for DOM to render, then focus first element
    queueTask(() => {
      this.updateFocusableElements();
      this.focusFirstElement();
    });
  }

  private onClose() {
    this.cleanupModal();
  }

  private cleanupModal() {
    // Restore body scroll
    document.body.style.overflow = '';

    // Return focus to element that opened the modal
    if (this.previousActiveElement) {
      this.previousActiveElement.focus();
      this.previousActiveElement = undefined;
    }
  }

  private updateFocusableElements() {
    if (!this.modalElement) return;

    const focusableSelectors = [
      'a[href]',
      'button:not([disabled])',
      'textarea:not([disabled])',
      'input:not([disabled])',
      'select:not([disabled])',
      '[tabindex]:not([tabindex="-1"])'
    ].join(', ');

    this.focusableElements = Array.from(
      this.modalElement.querySelectorAll(focusableSelectors)
    ) as HTMLElement[];
  }

  private focusFirstElement() {
    const firstFocusable = this.focusableElements[0];
    if (firstFocusable) {
      firstFocusable.focus();
    }
  }

  private handleTabKey(event: KeyboardEvent) {
    if (this.focusableElements.length === 0) return;

    const firstElement = this.focusableElements[0];
    const lastElement = this.focusableElements[this.focusableElements.length - 1];
    const activeElement = document.activeElement as HTMLElement;

    if (event.shiftKey) {
      // Shift + Tab: Move backwards
      if (activeElement === firstElement) {
        event.preventDefault();
        lastElement.focus();
      }
    } else {
      // Tab: Move forwards
      if (activeElement === lastElement) {
        event.preventDefault();
        firstElement.focus();
      }
    }
  }
}

Usage Examples

Basic Modal

Confirmation Dialog

Form Modal

Full-Screen Modal

Testing

Accessibility Features

This modal follows WCAG 2.1 guidelines:

  • ✅ Focus Trap: Tab key cycles through focusable elements within modal

  • ✅ Focus Management: Focuses first element when opened, returns focus when closed

  • ✅ Keyboard Support: Escape key closes modal

  • ✅ ARIA Attributes: role="dialog", aria-modal="true" for screen readers

  • ✅ Body Scroll Lock: Prevents scrolling background content

Enhancements

1. Add Transition Animations

Use Aurelia's animation system for smoother transitions:

2. Add Confirmation Before Close

3. Add Modal Service

Create a global modal service for programmatic modals:

Best Practices

  1. Focus Management: Always return focus to the trigger element

  2. Body Scroll: Lock body scroll to prevent confusion

  3. Escape Key: Always allow Escape to close (unless critical action)

  4. Backdrop Click: Make it configurable, disable for forms with unsaved changes

  5. Portal Rendering: For complex apps, render modals in a portal at document root

  6. Stacking: Support multiple modals with z-index management

Summary

You've built a fully-featured modal dialog with:

  • ✅ Smooth animations

  • ✅ Focus trap and management

  • ✅ Keyboard support

  • ✅ Accessible markup

  • ✅ Multiple sizes

  • ✅ Customizable behavior

This modal is production-ready and handles all common use cases!

Last updated

Was this helpful?