<div id="root"></div>
body {
  height: 100vh;
  margin: 0;
  display: grid;
  place-items: center;
}

button {
  border-radius: 8px;
  border: 2px solid transparent;
  padding: 0.6em 1.2em;
  font-size: 1em;
  font-weight: 500;
  font-family: inherit;
  background-color: #f9f9f9;
  cursor: pointer;
  transition: border-color 0.25s;
}

button:hover {
  border-color: #646cff;
}

button:focus,
button:focus-visible {
  outline: 4px auto #646cff;
}

.c-dialog {
  width: 100%;
  max-width: 500px;
  padding: 0;
  border-radius: 10px;
  border: none;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
  text-align: center;
}

.c-dialog__inner {
  padding: 20px;
}

.c-dialog__inner h2 {
  font-size: 24px;
  font-weight: 700;
}

.c-dialog__inner p {
  font-size: 14px;
  margin-top: 12px;
}

.c-btnGroup {
  width: 100%;
  display: flex;
  justify-content: center;
  gap: 10px;
  margin-top: 24px;
}
View Compiled
import React, { useCallback, useEffect, useRef, useState } from "https://esm.sh/react@18";
import ReactDOM from "https://esm.sh/react-dom@18";

const App = () => {
  const dialogRef = useRef(null);
  const [trigger, setTrigger] = useState(null);

  const openModal = useCallback(() => {
    if (dialogRef.current) {
      setTrigger(document.activeElement);
      dialogRef.current.showModal();
      document.body.style.overflow = "hidden";
    }
  }, []);

  const closeModal = useCallback(() => {
    if (dialogRef.current) {
      dialogRef.current.close();
      trigger?.focus();
      document.body.style.overflow = "";
    }
  }, [trigger]);

  /**
   * フォーカストラップ
   */
  useEffect(() => {
    const dialogEl = dialogRef.current;
    const focusableElements = Array.from(
      dialogEl?.querySelectorAll(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      ) ?? []
    );
    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];

    const handleKeyDown = (e) => {
      if (e.key === "Tab") {
        if (e.shiftKey && document.activeElement === firstElement) {
          e.preventDefault();
          lastElement.focus();
        } else if (!e.shiftKey && document.activeElement === lastElement) {
          e.preventDefault();
          firstElement.focus();
        }
      }
    };

    dialogEl?.addEventListener("keydown", handleKeyDown);
    return () => {
      dialogEl?.removeEventListener("keydown", handleKeyDown);
    };
  }, [dialogRef]);

  /**
   * モーダルの外側をクリックしたときにモーダルを閉じる
   */
  useEffect(() => {
    const dialogEl = dialogRef.current;

    const handleBackdropClick = (event) => {
      if (event.target === dialogEl && event.target !== dialogEl?.firstChild) {
        closeModal();
      }
    };

    dialogEl?.addEventListener("click", handleBackdropClick);
    return () => {
      dialogEl?.removeEventListener("click", handleBackdropClick);
    };
  }, [closeModal]);

  /**
   * モーダルを開いたときにスクロールを禁止する
   * (コンポーネントがアンマウントされるときにスクロールを有効に戻す)
   */
  useEffect(() => {
    return () => {
      document.body.style.overflow = "";
    };
  }, []);

  return (
    <>
      <button onClick={() => openModal()}>Open Modal</button>
      <dialog
        className="c-dialog"
        ref={dialogRef}
        aria-labelledby="title"
        aria-describedby="desc"
      >
        <div className="c-dialog__inner">
          <h2 id="title">タイトル</h2>
          <p id="desc">
            このモーダルは、HTMLのdialog要素を使って実装されています。
          </p>
          <div class="c-btnGroup">
          <button onClick={() => closeModal()}>Close Modal</button>
          <button
            onClick={() => {
              alert("成功");
              closeModal();
            }}
          >
            アクション
          </button>
          </div>
        </div>
      </dialog>
    </>
  );
}

ReactDOM.render(<App />,
document.getElementById("root"))
View Compiled

External CSS

  1. https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.4/css/bulma.min.css

External JavaScript

This Pen doesn't use any external JavaScript resources.