React cookbook · Intermediate

Build an accessible modal dialog

Show a confirmation dialog that traps focus, closes with Escape and restores focus afterwards.

Approach

Use the native <dialog> element with showModal(): browsers handle focus trapping, the backdrop, Escape to close and inertness of the rest of the page. Label it with aria-labelledby, move focus to the safest action, and return focus to the trigger on close.

Solution

import { useEffect, useId, useRef } from "react";

type Props = { open: boolean; title: string; children: React.ReactNode; onConfirm: () => void; onClose: () => void };

export function ConfirmDialog({ open, title, children, onConfirm, onClose }: Props) {
  const ref = useRef<HTMLDialogElement>(null);
  const titleId = useId();
  const opener = useRef<Element | null>(null);

  useEffect(() => {
    const dialog = ref.current;
    if (!dialog) return;
    if (open && !dialog.open) {
      opener.current = document.activeElement;
      dialog.showModal();
    } else if (!open && dialog.open) {
      dialog.close();
    }
  }, [open]);

  return (
    <dialog
      ref={ref}
      aria-labelledby={titleId}
      onClose={() => {
        onClose();
        (opener.current as HTMLElement | null)?.focus();     // restore focus
      }}
    >
      <h2 id={titleId}>{title}</h2>
      <div>{children}</div>
      <form method="dialog">
        <button autoFocus value="cancel">Cancel</button>
        <button value="confirm" onClick={onConfirm}>Delete</button>
      </form>
    </dialog>
  );
}

Pitfalls

  • Div-based modals without focus trapping or Escape handling.
  • Autofocusing the destructive action.
  • Not restoring focus to the triggering button.

Variations

  • Use React Aria's useDialog/Modal or Radix Dialog for complex needs.
  • Render into a portal when stacking contexts interfere.

Further reading: MDN: <dialog>

Reference: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog

More React recipes