ReactでHTMLのdialog要素を使い、モーダルを作る方法
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 コンポーネントです。
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 に戻します。検証に使った画面から、コンポーネントの中身だけを抜き出し、検証用の切り替え・記録・後ろのボタンなどを除いたものです。
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 キーを押し、もう一度「保存の確認を開く」を押した結果です。
開いたあと: {"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 に戻りました。
Esc キーを押したあと: {"state":"React の open:false/結果:ボタン以外で閉じた","dialogOpen":false,"modal":false,"focus":"button「保存の確認を開く」","logs":["onClose(returnValue=\"\")"]}

公式リファレンスの useEffect の例は、ダイアログの中の「Close」ボタンで state を false にする作りで、onClose は付いていません。同じ作りのコンポーネントを TypeScript で書いて試したところ、Esc キーで閉じたあとは、onClose なしの版と同じく、もう一度開けなくなりました。Esc キーで閉じたあとも開き直せるようにするには、onClose を足します。
フォームのボタンで閉じて、押したボタンを受け取る
ダイアログの中に method="dialog" の <form> を置くと、そのフォームのボタンを押したときにダイアログが閉じます。このとき、押したボタンの value が、<dialog> の returnValue に入ります(MDN の dialog 要素のページの補足)。Modal は、この returnValue を onClose に渡しています。
「保存する」で閉じた場合と、そのあともう一度開いて Esc キーで閉じた場合の結果です。
開いて「保存する」を押したあと: {"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 に入れました。
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行目の「結果:ボタン以外で閉じた」は、その前の操作で閉じたときの値です。
開いて、ダイアログの中の文をクリックしたあと: {"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 の場所と、開いたときの位置の行を抜き出しました。
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 に邪魔されません。

ダイアログを開いたまま、後ろにある「後ろのボタン」の位置をクリックしました。その位置にあった要素は dialog(幕の上のクリックが届く要素)で、ボタンの回数は 0 回のままでした。幕のクリックなので、ダイアログは閉じました。
Tab キーを4回押すと、フォーカスは「保存する」→ body →「やめる」→「保存する」と移りました。body は、ページのどの要素にもフォーカスがない状態です。後ろのボタンには移りませんでした。WAI-ARIA のモーダルダイアログのパターンではダイアログの中だけを巡回する作りですが、Chromium の <dialog> では、フォーカスが一度ページの外に出ました。
開いたときと閉じたときのフォーカス
開いた直後のフォーカスは、ダイアログの中の最初のボタン「やめる」にありました。閉じたあとは、開く前に押した「保存の確認を開く」に戻りました(上の結果の focus)。MDN の dialog 要素のページのアクセシビリティの節にも、showModal() で開くと、中の最初のフォーカスできる要素にフォーカスが移ると書かれています。WAI-ARIA のパターンでも、閉じたらダイアログを開いた要素にフォーカスを戻すよう書かれています。
最初のフォーカスを別のボタンにしたいとき、MDN は autofocus 属性を付けるよう勧めています。ところが、React の autoFocus を「保存する」に付けても、開いたときのフォーカスは「やめる」のままでした。検証の出力から抜き出した結果です。
「保存する」に autofocus 属性があるか: false
開いたあと: {"state":"React の open:true/結果:まだ閉じていない","dialogOpen":true,"modal":true,"focus":"button「やめる」","logs":[]}
React は autoFocus を HTML の autofocus 属性として出力していませんでした。showModal() が探す属性がないので、最初のボタンが選ばれます。代わりに、showModal() のすぐあとで、自分で focus() を呼ぶ版も試しました。
if (open && !dialog.open) {
dialog.showModal();
dialog.querySelector<HTMLElement>('[data-autofocus]')?.focus();
} else if (!open && dialog.open) {
dialog.close();
}
「保存する」に data-autofocus を付けておくと、開いたときのフォーカスは「保存する」に移りました。この版でも、Esc キーで閉じたあとのフォーカスは「保存の確認を開く」に戻りました。いちばん簡単なのは、最初にフォーカスさせたいボタンを、ダイアログの中で最初に置くことです。





