[]previous and next page

Modal dialog

Build an accessible modal dialog with a portal, focus management, Escape and backdrop dismissal, and a Tab focus trap.

For agentsUpdated

This recipe builds a Modal component that renders into document.body with Portal, labels itself with an id from useId, moves focus in when it opens, and gives focus back to the opener when it closes. It demonstrates Portal, useId, onMount, onCleanup, and the way a component mounted under <Show> is created and disposed with its condition.

The component#

Modal is mounted only while its parent renders it, so "open" is the parent's <Show>, not a prop of the modal. Everything that must be undone when the dialog closes (the document listener, the focus) is registered in the modal's own owner and is undone when that owner is disposed.

src/modal.tsxtsx
import { onCleanup, onMount, useId, useStore } from "@shadow-js/core";
import type { ParentProps } from "@shadow-js/web";
import { Portal, Show } from "@shadow-js/web";

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

interface ModalProps {
  readonly title: string;
  readonly onClose: () => void;
}

export function Modal(props: ParentProps<ModalProps>) {
  const titleId = useId();
  // The element that had focus when the dialog was created is the one to return to.
  const opener = document.activeElement;
  let dialog: HTMLElement | undefined;

  const focusables = (): HTMLElement[] =>
    dialog === undefined ? [] : Array.from(dialog.querySelectorAll<HTMLElement>(FOCUSABLE));

  const onKeyDown = (event: KeyboardEvent): void => {
    if (event.key === "Escape") {
      event.preventDefault();
      props.onClose();
      return;
    }
    if (event.key !== "Tab" || dialog === undefined) return;
    const items = focusables();
    const first = items[0];
    const last = items[items.length - 1];
    if (first === undefined || last === undefined) {
      event.preventDefault();
      dialog.focus();
      return;
    }
    const active = document.activeElement;
    if (!dialog.contains(active)) {
      event.preventDefault();
      first.focus();
    } else if (event.shiftKey && (active === first || active === dialog)) {
      event.preventDefault();
      last.focus();
    } else if (!event.shiftKey && active === last) {
      event.preventDefault();
      first.focus();
    }
  };

  onMount(() => {
    (focusables()[0] ?? dialog)?.focus();
    document.addEventListener("keydown", onKeyDown);
  });

  onCleanup(() => {
    document.removeEventListener("keydown", onKeyDown);
    if (opener instanceof HTMLElement) opener.focus();
  });

  return (
    <Portal>
      <div
        class="backdrop"
        onClick={(event) => {
          if (event.target === event.currentTarget) props.onClose();
        }}
      >
        <div
          role="dialog"
          aria-modal="true"
          aria-labelledby={titleId}
          tabindex="-1"
          ref={(element) => {
            dialog = element;
          }}
        >
          <h2 id={titleId}>{props.title}</h2>
          {props.children}
        </div>
      </div>
    </Portal>
  );
}

export function App() {
  const [open, setOpen] = useStore(false);
  return (
    <div>
      <button type="button" onClick={() => setOpen(true)}>
        Open settings
      </button>
      <Show when={open()}>
        <Modal title="Settings" onClose={() => setOpen(false)}>
          <label>
            Display name
            <input type="text" />
          </label>
          <button type="button" onClick={() => setOpen(false)}>
            Save
          </button>
          <button type="button" onClick={() => setOpen(false)}>
            Cancel
          </button>
        </Modal>
      </Show>
    </div>
  );
}

Decisions#

Portal. Portal with no mount inserts its children directly into document.body, with no wrapper element, so the dialog escapes any overflow: hidden or stacking context of the page. The call site renders nothing. Delegated events such as onClick keep working because they bubble through the body to the document listener.

Labeling. useId() returns an id that is stable between server and client and safe in id and aria-* references. The <h2> gets the id and the dialog points at it with aria-labelledby, so the accessible name is the title. aria-modal="true" tells assistive technology that the rest of the page is inert; it does not stop Tab, which is why the component traps it.

onMount for the document listener. onMount runs after the nodes exist and never on the server, so document and the dialog reference are available. Adding the listener there, and removing it in onCleanup, ties the listener to the lifetime of the modal rather than to a signal. Forgetting the removal leaves a handler that calls a stale props.onClose for every later Escape.

Focus. The opener is read in the component body, before anything moves focus. onMount focuses the first focusable child, or the dialog itself (tabindex="-1") when there is none. onCleanup restores focus after the dialog closes by any path: Escape, backdrop, a button, or the parent unmounting.

Backdrop. event.target === event.currentTarget is true only for a click on the backdrop itself. A click inside the dialog has the dialog's descendant as its target and is ignored.

Focus trap. One document keydown handler covers Escape and Tab. At the last focusable element Tab wraps to the first, and Shift+Tab at the first wraps to the last. When focus is outside the dialog, Tab pulls it back in. This is a minimal trap, not a replacement for the inert attribute on the page behind the dialog.

The dialog lives in document.body, outside the render container, so queries bound to the render result still find it (they search the base element, which is the body).

Related: Portal, Show, useId, onMount, onCleanup, render, userEvent.