Skip to content

Modal ​

A modal with open/close behaviour, backdrop overlay, focus trapping, and Escape to close. This example demonstrates refs, events, the keyed service hook, and the trapFocus / untrapFocus utilities.

What we're building ​

A button that opens a modal. When open, the modal:

  • Displays a backdrop overlay
  • Traps keyboard focus inside the dialog (Tab cycles through focusable elements)
  • Closes on Escape key press
  • Closes when clicking the backdrop or a close button
  • Restores focus to the trigger button on close

HTML markup ​

The trigger button and the modal markup live inside the same component. The backdrop and dialog are separate refs. The dialog has role="dialog" and aria-modal="true" for accessibility:

html
<div data-component="Modal">
  <button data-ref="openBtn">Open Modal</button>

  <div data-ref="backdrop" class="modal-backdrop" hidden>
    <div
      data-ref="dialog"
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title">
      <h2 id="modal-title">Modal Title</h2>
      <p>This is the modal content. Focus is trapped inside.</p>
      <button data-ref="closeBtn">Close</button>
    </div>
  </div>
</div>

Add some basic CSS to position the backdrop and dialog:

css
.modal-backdrop {
  position: fixed;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.5);
  z-index: 1000;
}

.modal-backdrop [role='dialog'] {
  background: white;
  padding: 2rem;
  border-radius: 0.5rem;
  max-width: 500px;
  width: 100%;
}

JavaScript component ​

The component uses trapFocus from @studiometa/js-toolkit/utils to keep Tab navigation inside the dialog, and untrapFocus to restore focus on close. The keyed hook listens for Escape:

js
import { Base } from '@studiometa/js-toolkit';
import {
  trapFocus,
  untrapFocus,
  keyCodes,
} from '@studiometa/js-toolkit/utils';

export default class Modal extends Base {
  static config = {
    name: 'Modal',
    refs: ['openBtn', 'closeBtn', 'backdrop', 'dialog'],
  };

  isOpen = false;

  /**
   * Open the modal when the open button is clicked.
   */
  onOpenBtnClick() {
    this.open();
  }

  /**
   * Close the modal when the close button is clicked.
   */
  onCloseBtnClick() {
    this.close();
  }

  /**
   * Close the modal when clicking the backdrop (but not the dialog itself).
   */
  onBackdropClick({ event }) {
    if (event.target === this.$refs.backdrop) {
      this.close();
    }
  }

  /**
   * Close on Escape key press.
   */
  keyed(props) {
    if (this.isOpen && props.ESC && props.isDown) {
      this.close();
    }

    // Trap focus inside the dialog while open
    if (this.isOpen && props.event.keyCode === keyCodes.TAB) {
      trapFocus(this.$refs.dialog, props.event);
    }
  }

  /**
   * Show the modal and trap focus.
   */
  open() {
    this.isOpen = true;
    this.$refs.backdrop.hidden = false;
    // Focus the first focusable element inside the dialog
    this.$refs.dialog.focus?.() ||
      this.$refs.dialog
        .querySelector('button, [href], input, select, textarea')
        ?.focus();
  }

  /**
   * Hide the modal and restore focus.
   */
  close() {
    this.isOpen = false;
    this.$refs.backdrop.hidden = true;
    untrapFocus(); // Restores focus to the element focused before the modal opened
  }
}

App setup ​

js
import { registerComponent } from '@studiometa/js-toolkit';
import Modal from './Modal.js';

registerComponent(Modal);

Further reading ​

MIT Licensed