Modal

A centered overlay dialog for confirmations, alerts, forms, and general user interactions. Uses native <dialog> element with fade animation and focus management.

View DESIGN.md View Lookbook

Examples

Small Modal (400px)

Medium Modal (560px) — Default

Large Modal (800px)

Extra Large Modal (1000px)

Modal with Backdrop Click Disabled

HTML Structure

Basic Modal Template

<!-- Trigger Button -->
<button
  data-modal="unique-modal-id"
  role="button"
  aria-haspopup="dialog"
  aria-expanded="false"
  aria-controls="unique-modal-id">
  Open Modal
</button>

<!-- Modal Dialog -->
<dialog
  id="unique-modal-id"
  class="elv-modal elv-modal--md"
  aria-modal="true"
  role="dialog">

  <div class="elv-modal__inner">

    <!-- Header -->
    <div class="elv-modal__header">
      <div class="elv-modal__header-top">
        <div>
          <h2 class="elv-modal__title">Modal Title</h2>
          <p class="elv-modal__subtitle">Optional subtitle or description</p>
        </div>
        <button
          class="elv-modal__close"
          aria-label="Close modal">
          <span class="material-symbols-outlined">close</span>
        </button>
      </div>
    </div>

    <!-- Body -->
    <div class="elv-modal__body">
      <p>Modal content goes here. This area is scrollable when content overflows.</p>
    </div>

    <!-- Footer (optional) -->
    <div class="elv-modal__footer">
      <button class="btn btn--secondary">Cancel</button>
      <button class="btn btn--primary">Confirm</button>
    </div>

  </div>
</dialog>

Size Variants

<!-- Small: 400px max-width -->
<dialog class="elv-modal elv-modal--sm">...</dialog>

<!-- Medium (default): 560px max-width -->
<dialog class="elv-modal elv-modal--md">...</dialog>

<!-- Large: 800px max-width -->
<dialog class="elv-modal elv-modal--lg">...</dialog>

<!-- Extra Large: 1000px max-width -->
<dialog class="elv-modal elv-modal--xl">...</dialog>

Disable Backdrop Click to Close

<!-- Add data-dismiss-on-backdrop-click="false" to prevent closing when backdrop is clicked -->
<dialog
  id="modal-id"
  class="elv-modal elv-modal--md"
  data-dismiss-on-backdrop-click="false"
  aria-modal="true"
  role="dialog">
  ...
</dialog>

JavaScript Controller

Vanilla JavaScript implementation with focus trap, focus restoration, and accessibility features.

/**
 * Modal Controller
 *
 * Features:
 * - Native <dialog> element with showModal() API
 * - Fade-in animation (200ms ease-in-out)
 * - Centered positioning (default dialog behavior)
 * - Focus trap (tab cycles within modal)
 * - Focus restoration to trigger element
 * - Click outside to close (configurable)
 * - ESC key to close (native)
 * - Body scroll lock when open
 * - ARIA state management
 */
class Modal {
  constructor(dialogElement) {
    this.dialog = dialogElement;
    this.triggerElement = null;
    this.focusableElements = [];
    // Check if backdrop click should close modal (default: true)
    this.dismissOnBackdropClick =
      this.dialog.getAttribute('data-dismiss-on-backdrop-click') !== 'false';

    this.init();
  }

  init() {
    // Add body scroll lock class (activates when any dialog[open] exists)
    if (!document.body.classList.contains('elv-has-dialog-scroll-lock')) {
      document.body.classList.add('elv-has-dialog-scroll-lock');
    }

    // Bind event listeners
    this.dialog.addEventListener('click', this.handleBackdropClick.bind(this));
    this.dialog.addEventListener('keydown', this.handleKeyDown.bind(this));

    // Find and setup close buttons
    const closeButtons = this.dialog.querySelectorAll('.elv-modal__close');
    closeButtons.forEach(btn => {
      btn.addEventListener('click', () => this.close());
    });
  }

  open(triggerElement) {
    // Store trigger for focus restoration
    this.triggerElement = triggerElement;

    // Update trigger ARIA state
    if (this.triggerElement) {
      this.triggerElement.setAttribute('aria-expanded', 'true');
    }

    // Close any other open dialogs
    document.querySelectorAll('dialog[open]').forEach(d => {
      if (d !== this.dialog) {
        d.close();
      }
    });

    // Open the dialog
    this.dialog.showModal();

    // Setup focus trap
    this.setupFocusTrap();

    // Focus first element
    requestAnimationFrame(() => {
      this.focusFirstElement();
    });
  }

  close() {
    // Close the dialog
    this.dialog.close();

    // Update trigger ARIA state
    if (this.triggerElement) {
      this.triggerElement.setAttribute('aria-expanded', 'false');
      this.triggerElement.focus();
    }
  }

  setupFocusTrap() {
    // Get all focusable elements within dialog
    const focusableSelector =
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
    this.focusableElements = Array.from(
      this.dialog.querySelectorAll(focusableSelector)
    );
  }

  focusFirstElement() {
    if (this.focusableElements.length > 0) {
      this.focusableElements[0].focus();
    }
  }

  handleBackdropClick(event) {
    // Close if click is directly on dialog element (backdrop), not on content
    // Only if dismissOnBackdropClick is enabled
    if (event.target === this.dialog && this.dismissOnBackdropClick) {
      this.close();
    }
  }

  handleKeyDown(event) {
    // Focus trap: cycle focus within modal
    if (event.key === 'Tab' && this.focusableElements.length > 0) {
      const firstElement = this.focusableElements[0];
      const lastElement = this.focusableElements[this.focusableElements.length - 1];

      if (event.shiftKey) {
        // Shift+Tab: if on first element, jump to last
        if (document.activeElement === firstElement) {
          event.preventDefault();
          lastElement.focus();
        }
      } else {
        // Tab: if on last element, jump to first
        if (document.activeElement === lastElement) {
          event.preventDefault();
          firstElement.focus();
        }
      }
    }
  }
}

// Initialize all modals on page load
document.addEventListener('DOMContentLoaded', () => {
  // Create modal instances
  const modals = new Map();

  document.querySelectorAll('.elv-modal').forEach(dialog => {
    modals.set(dialog.id, new Modal(dialog));
  });

  // Setup trigger buttons
  document.querySelectorAll('[data-modal]').forEach(trigger => {
    trigger.addEventListener('click', (e) => {
      const modalId = trigger.getAttribute('data-modal');
      const modal = modals.get(modalId);
      if (modal) {
        modal.open(trigger);
      }
    });

    // Keyboard support for triggers
    trigger.addEventListener('keydown', (e) => {
      if (e.key === 'Enter' || e.key === ' ') {
        e.preventDefault();
        trigger.click();
      }
    });
  });
});

Usage Notes

When to Use Modal vs Slide-Out Panel

Accessibility Requirements

Behavior Specifications

Configuration Options

Size Guidelines

Design Specifications (from DESIGN.md)

Browser Support

Uses native <dialog> element (Chrome 37+, Firefox 98+, Safari 15.4+) and CSS :has() selector (Chrome 105+, Firefox 121+, Safari 15.4+). For older browsers, consider a polyfill or fallback implementation.

Delete Item

This action cannot be undone

Are you sure you want to delete this item? This action is permanent and cannot be reversed.

Create New Project

Enter project details to get started

This is the default modal size using elv-modal--md with a max-width of 560px.

When to use Medium Modal:

  • Standard forms with 3-5 fields
  • Confirmation dialogs with moderate content
  • Settings or preferences panels
  • General-purpose dialogs

Key Features

Centered Position

Modal is centered both vertically and horizontally on screen.

Fade Animation

Smoothly fades in over 200ms with a subtle scale effect.

Review Submission

800px wide modal for content-rich interfaces

This large modal uses elv-modal--lg with a max-width of 800px.

When to use Large Modal:

  • Complex forms with 6+ fields or multiple sections
  • Content-rich dialogs with tables, lists, or cards
  • Multi-step wizards requiring more horizontal space
  • Preview or review screens with detailed information

Scrollable Content Demo

The body section is scrollable when content exceeds the viewport height. The header and footer remain fixed while the body scrolls.

Scroll down to see more content...

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

Keep scrolling...

You've reached the bottom!

Notice how the header and footer stayed in place while you scrolled.

Data Analysis Dashboard

1000px wide modal for complex interfaces

This extra large modal uses elv-modal--xl with a max-width of 1000px.

When to use Extra Large Modal:

  • Multi-column layouts requiring significant horizontal space
  • Data-heavy interfaces with tables, charts, or dashboards
  • Rich text editors or WYSIWYG interfaces
  • Complex configuration screens with many options
  • Side-by-side comparisons or before/after views

Accessibility Features

Focus Trap

Tab key cycles through focusable elements within the modal. Try it by pressing Tab!

Keyboard Navigation

Press ESC to close. Focus automatically returns to the trigger button.

Click Outside to Close

Clicking the semi-transparent backdrop closes the modal (unless disabled).

ARIA State Management

Screen readers announce modal opening/closing via aria-expanded and aria-modal.

Important Form

Backdrop click disabled for this modal

This modal has data-dismiss-on-backdrop-click="false" set on the dialog element.

Try clicking the backdrop (the dark area outside this modal). Notice that the modal does not close.

This is useful for:

  • Required forms that must be completed
  • Critical confirmations that shouldn't be accidentally dismissed
  • Multi-step workflows where accidental closing would lose progress
  • Legal agreements or important disclosures

Users can still close via:

  • The close button (X icon in header)
  • ESC key
  • Any custom close/cancel buttons in footer