ReactでHTMLのdialog要素を使い、モーダルを作る方法

​​React​dialog,Playwright,TypeScript,アクセシビリティ,モーダル

HTML の <dialog> 要素を showModal() で開くと、モーダルに必要な処理の多くをブラウザが受け持ってくれます。Esc キーで閉じる、後ろの画面を操作できなくする、閉じたあとに元のボタンへフォーカスを戻す、といった処理を自分で書かずに済みます。React で使うときに気をつけるのは、ブラウザが閉じたダイアログと、React の state をずれないようにつなぐことです。

筆者は検証環境で、<dialog> を使ったモーダルのコンポーネントを作り、開く・閉じる・フォーカスの動きを Chromium で確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で変換し、Chromium 141 を Playwright 1.56.1 で操作して確かめました。ほかのブラウザでは試していません。

ポイント

開くときは useEffect から showModal() を、閉じるときは close() を呼びます。Esc キーなど、ブラウザの側で閉じることもあるので、<dialog> の onClose で React の state を false に戻します。

showModal と close を useEffect から呼ぶ

<dialog> には open 属性もありますが、MDN の dialog 要素のページの open 属性の説明によると、open 属性で開いたダイアログはモーダルになりません。MDN は同じ箇所で、表示には show() か showModal() を使うよう勧めています。モーダルにするのは showModal() です。

showModal() と close() は DOM のメソッドなので、React では ref で <dialog> を受け取り、useEffect の中から呼びます。公式リファレンスの useEffect のページにある、モーダルダイアログを操作する例も、Effect で showModal() と close() を呼んでいます。

検証に使った Modal コンポーネントです。

tsx
import { useEffect, useId, useRef, type ReactNode } from 'react';

type Props = {
  open: boolean;
  title: string;
  // 閉じたときに、dialog の returnValue を渡して呼ぶ
  onClose: (returnValue: string) => void;
  children: ReactNode;
};

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

  // open の値に合わせて、dialog を開いたり閉じたりする
  useEffect(() => {
    const dialog = ref.current;
    if (!dialog) return;
    if (open && !dialog.open) {
      dialog.returnValue = ''; // 前に閉じたときの値を消してから開く
      dialog.showModal();
    } else if (!open && dialog.open) {
      dialog.close();
    }
  }, [open]);

  return (
    <dialog
      ref={ref}
      aria-labelledby={titleId}
      // Esc キー、method="dialog" のフォーム、close() のどれで閉じても呼ばれる
      onClose={(e) => onClose(e.currentTarget.returnValue)}
      // 幕(::backdrop)の上のクリックは、dialog 自身へのクリックとして届く
      onClick={(e) => {
        if (e.target === e.currentTarget) e.currentTarget.close();
      }}
    >
      <div className="modal-body">
        <h3 id={titleId}>{title}</h3>
        {children}
      </div>
    </dialog>
  );
}

<dialog> の要素は、閉じているあいだも DOM に置いたままにします。open が true になったら showModal() を呼び、false になったら close() を呼びます。dialog.open を見ているのは、すでに開いているダイアログで showModal() を呼ばないためです。

使う側は、開くかどうかを state で持ち、閉じたら false に戻します。検証に使った画面から、コンポーネントの中身だけを抜き出し、検証用の切り替え・記録・後ろのボタンなどを除いたものです。

tsx
const [open, setOpen] = useState(false);
const [result, setResult] = useState('まだ閉じていない');

<button className="open" onClick={() => setOpen(true)}>保存の確認を開く</button>
<Modal
  open={open}
  title="保存しますか?"
  onClose={(value) => {
    setOpen(false);
    setResult(value === '' ? 'ボタン以外で閉じた' : value);
  }}
>
  <p className="message">変更した内容を保存します。</p>
  <form method="dialog">
    <button value="cancel">やめる</button>
    <button value="save">保存する</button>
  </form>
</Modal>

Esc キーで閉じたら、onClose で state を戻す

showModal() で開いたダイアログは、Esc キーで閉じます(MDN の dialog 要素のページのアクセシビリティの節)。このとき閉じるのはブラウザで、React の state は変わりません。React に知らせるのが、<dialog> の onClose です。公式リファレンスの、div などの共通コンポーネントの props の説明には、<dialog> だけで使えるイベントとして onClose と onCancel が載っています。

onClose を付けない版で、「保存の確認を開く」を押し、Esc キーを押し、もう一度「保存の確認を開く」を押した結果です。

text
開いたあと: {"state":"React の open:true/結果:まだ閉じていない","dialogOpen":true,"modal":true,"focus":"button「やめる」","logs":[]}
Esc キーを押したあと: {"state":"React の open:true/結果:まだ閉じていない","dialogOpen":false,"modal":false,"focus":"button「保存の確認を開く」","logs":[]}
もう一度「保存の確認を開く」を押したあと: {"state":"React の open:true/結果:まだ閉じていない","dialogOpen":false,"modal":false,"focus":"button「保存の確認を開く」","logs":[]}

state は画面に出している React の state、dialogOpen は <dialog> の open プロパティです。onClose がないと、Esc キーでダイアログは閉じたのに、React の open は true のままでした。もう一度開こうとしても、setOpen(true) では値が変わりません。Effect も動かないので、ダイアログは開きませんでした。

onClose を付けた Modal では、Esc キーで閉じると onClose が呼ばれ、open は false に戻りました。

text
Esc キーを押したあと: {"state":"React の open:false/結果:ボタン以外で閉じた","dialogOpen":false,"modal":false,"focus":"button「保存の確認を開く」","logs":["onClose(returnValue=\"\")"]}
前半の onClose なしでは、Esc キーでダイアログが閉じても React の open は true のままで、もう一度ボタンを押してもダイアログは開かない。後半の onClose ありでは、Esc キーで open が false になり、もう一度押すとダイアログが開く
onClose なし・ありで、開く、Esc キー、もう一度開くを順に操作したときの動き

公式リファレンスの useEffect の例は、ダイアログの中の「Close」ボタンで state を false にする作りで、onClose は付いていません。同じ作りのコンポーネントを TypeScript で書いて試したところ、Esc キーで閉じたあとは、onClose なしの版と同じく、もう一度開けなくなりました。Esc キーで閉じたあとも開き直せるようにするには、onClose を足します。

フォームのボタンで閉じて、押したボタンを受け取る

ダイアログの中に method="dialog" の <form> を置くと、そのフォームのボタンを押したときにダイアログが閉じます。このとき、押したボタンの value が、<dialog> の returnValue に入ります(MDN の dialog 要素のページの補足)。Modal は、この returnValue を onClose に渡しています。

「保存する」で閉じた場合と、そのあともう一度開いて Esc キーで閉じた場合の結果です。

text
開いて「保存する」を押したあと: {"state":"React の open:false/結果:save","dialogOpen":false,"modal":false,"focus":"button「保存の確認を開く」","logs":["onClose(returnValue=\"save\")"]}
もう一度開いて Esc キーを押したあと: {"state":"React の open:false/結果:ボタン以外で閉じた","dialogOpen":false,"modal":false,"focus":"button「保存の確認を開く」","logs":["onClose(returnValue=\"\")"]}

「保存する」で閉じると returnValue は save、Esc キーで閉じると空の文字列でした。どのボタンで閉じたかを、onClick を1つずつ書かずに受け取れます。

MDN の returnValue のページには、Esc キーで閉じたときは returnValue が設定されないと書かれています。前に閉じたときの save が残らないように、Modal では開く前に returnValue を空に戻しています。

幕のクリックで閉じる

showModal() で開くと、ダイアログの後ろに ::backdrop という幕が出ます。MDN の closedby 属性の説明によると、showModal() で開いたダイアログは、何も指定しなければ Esc キーなどでは閉じますが、外のクリックでは閉じません。

Modal では、<dialog> の onClick で、クリックされたのが <dialog> 自身のときだけ close() を呼んでいます。幕の上のクリックは、<dialog> へのクリックとして届くからです。ダイアログの中の文をクリックしても閉じないように、<dialog> の余白を 0 にして、中身は内側の div に入れました。

css
dialog { padding: 0; border: none; border-radius: 10px; width: 340px; }
dialog::backdrop { background: rgba(31, 42, 48, 0.35); }
.modal-body { padding: 12px 18px 10px; }

検証の出力から、続けて行った2つの操作の結果を抜き出しました。1行目の「結果:ボタン以外で閉じた」は、その前の操作で閉じたときの値です。

text
開いて、ダイアログの中の文をクリックしたあと: {"state":"React の open:true/結果:ボタン以外で閉じた","dialogOpen":true,"modal":true,"focus":"dialog「保存しますか?変更した内容を保存します。やめる保存する」","logs":[]}
幕(ダイアログの外の左下)をクリックしたあと: {"state":"React の open:false/結果:ボタン以外で閉じた","dialogOpen":false,"modal":false,"focus":"button「保存の確認を開く」","logs":["onClose(returnValue=\"\")"]}

中の文のクリックでは開いたままで、幕のクリックで閉じました。close() で閉じたので、Esc キーのときと同じく onClose が呼ばれています。試しに <dialog> に 20px の余白を付けると、余白の上の点にある要素は dialog でした。余白を残すと、Modal の onClick の作りでは、そこのクリックでも閉じることになります。

onClick を書かずに、<dialog> に closedby="any" を付ける方法もあります。any にすると、外のクリックでも閉じるようになります。検証環境では、closedby="any" を付けた版でも、幕のクリックで閉じて onClose が呼ばれ、@types/react 19.3.0 の型チェックも通りました。ただし、2026年10月時点の MDN の browser-compat-data の記録では、closedby に対応したのは Chrome 134、Firefox 141 からで、Safari は Technology Preview だけです。

親の CSS に切り取られず、後ろの画面は操作できない

showModal() で開いたダイアログは、トップレイヤーという、ページのほかの要素より上の層に表示されます。ダイアログとその中身以外の要素は、inert 属性を付けたときのように操作できなくなります(MDN の showModal() のページ)。

これを確かめるため、ボタンを置いたカードに overflow: hidden と transform を付けました。MDN の position のページにある、固定配置の説明のとおり、position: fixed の要素は、祖先に transform があるとその祖先を基準に置かれます。筆者が以前、「ReactのcreatePortalで、モーダルを親要素の外に描画する方法」の記事で同じ条件を試したときは、position: fixed で自作したダイアログがカードの中で切り取られました。

<dialog> は、DOM の上ではカードの中にあります。検証の出力から、DOM の場所と、開いたときの位置の行を抜き出しました。

text
dialog の DOM の場所: {"parent":"card","inCard":true}
位置: {"card":"448x120 @(16,14)","dialog":"340x118 @(70,121)","centerIsDialog":true}

カードの高さは 120px(縦の位置は 14〜134px)ですが、ダイアログは 121〜239px の位置に、切り取られずに出ました。画面の中央の点にもダイアログがありました。createPortal で document.body の下に移さなくても、親の CSS に邪魔されません。

カードの上半分にボタンが並び、画面全体に灰色の幕がかかっている。白いダイアログは画面の中央にあり、カードの下の枠より下まで、切れずに表示されている
overflow: hidden と transform が付いたカードの中の dialog を、showModal() で開いた画面

ダイアログを開いたまま、後ろにある「後ろのボタン」の位置をクリックしました。その位置にあった要素は dialog(幕の上のクリックが届く要素)で、ボタンの回数は 0 回のままでした。幕のクリックなので、ダイアログは閉じました。

Tab キーを4回押すと、フォーカスは「保存する」→ body →「やめる」→「保存する」と移りました。body は、ページのどの要素にもフォーカスがない状態です。後ろのボタンには移りませんでした。WAI-ARIA のモーダルダイアログのパターンではダイアログの中だけを巡回する作りですが、Chromium の <dialog> では、フォーカスが一度ページの外に出ました。

開いたときと閉じたときのフォーカス

開いた直後のフォーカスは、ダイアログの中の最初のボタン「やめる」にありました。閉じたあとは、開く前に押した「保存の確認を開く」に戻りました(上の結果の focus)。MDN の dialog 要素のページのアクセシビリティの節にも、showModal() で開くと、中の最初のフォーカスできる要素にフォーカスが移ると書かれています。WAI-ARIA のパターンでも、閉じたらダイアログを開いた要素にフォーカスを戻すよう書かれています。

最初のフォーカスを別のボタンにしたいとき、MDN は autofocus 属性を付けるよう勧めています。ところが、React の autoFocus を「保存する」に付けても、開いたときのフォーカスは「やめる」のままでした。検証の出力から抜き出した結果です。

text
「保存する」に autofocus 属性があるか: false
開いたあと: {"state":"React の open:true/結果:まだ閉じていない","dialogOpen":true,"modal":true,"focus":"button「やめる」","logs":[]}

React は autoFocus を HTML の autofocus 属性として出力していませんでした。showModal() が探す属性がないので、最初のボタンが選ばれます。代わりに、showModal() のすぐあとで、自分で focus() を呼ぶ版も試しました。

tsx

if (open && !dialog.open) {
      dialog.showModal();
      dialog.querySelector<HTMLElement>('[data-autofocus]')?.focus();
    } else if (!open && dialog.open) {
      dialog.close();
    }

「保存する」に data-autofocus を付けておくと、開いたときのフォーカスは「保存する」に移りました。この版でも、Esc キーで閉じたあとのフォーカスは「保存の確認を開く」に戻りました。いちばん簡単なのは、最初にフォーカスさせたいボタンを、ダイアログの中で最初に置くことです。

​Posted by 86