Reactのinert属性で、モーダルの後ろを操作できなくする方法
自作のモーダルを開いている間は、後ろの画面を包む要素に inert 属性を付けると、後ろのボタンやリンクに Tab キーのフォーカスが移らず、クリックも届かなくなります。React 19 では inert={open} と真偽値を渡すだけで書けます。モーダル自体を inert の外に描画することと、閉じたときにフォーカスを戻す順番の2点に気をつければ、外側を操作できないモーダルになります。
検証環境は 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 です。ブラウザは Chromium 153 で、Playwright 1.63.0 から操作しました。比べるために React 18.3.1 も使っています。Firefox と Safari、実際のスクリーンリーダーでは試していません。
後ろの画面を包む要素に inert={open} を付ける
MDN の inert 属性のページによると、inert を付けた要素とその中の要素は、クリックしても click イベントが起きず、フォーカスもできなくなります。ページ内検索の対象からも、アクセシビリティツリーからも外れます。
検証では、メモ帳の画面に「設定」のモーダルを足しました。後ろの画面全体を <div id="page"> で包み、モーダルが開いている間だけ inert を付けます。モーダルは createPortal で document.body の直下に描画します。
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
type ModalProps = { onClose: () => void };
function Modal({ onClose }: ModalProps) {
const firstRef = useRef<HTMLButtonElement>(null);
// 開いたら中の最初のボタンへ。閉じたら、開く前の要素へ戻す
useEffect(() => {
const opener = document.activeElement as HTMLElement | null;
firstRef.current?.focus();
return () => opener?.focus();
}, []);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [onClose]);
return createPortal(
<div className="backdrop">
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal"
>
<h2 id="modal-title">設定</h2>
<button ref={firstRef} onClick={onClose}>
閉じる
</button>
<button onClick={onClose}>保存する</button>
</div>
</div>,
document.body,
);
}
export function App() {
const [open, setOpen] = useState(false);
const [count, setCount] = useState(0);
return (
<div id="page" inert={open}>
<h1>メモ帳</h1>
<p>
<a href="#help">ヘルプへのリンク</a>
</p>
<input aria-label="後ろの入力欄" />
<p>
<button id="open" onClick={() => setOpen(true)}>
設定を開く
</button>
</p>
<p>
<button id="back" onClick={() => setCount((c) => c + 1)}>
後ろのボタン
</button>
<output id="count">{count}回</output>
</p>
{open && <Modal onClose={() => setOpen(false)} />}
</div>
);
}
検証で動かしたコードは、URL のパラメーターで書き方を切り替える部分を足したものです。.backdrop は画面全体を覆う半透明の幕で、position: fixed で置いています。
<Modal> は React のツリーでは <div id="page"> の子ですが、DOM では document.body の直下に入ります。公式リファレンスの createPortal のページにあるとおり、ポータルが変えるのは DOM の上の置き場所だけです。inert は DOM の属性なので、DOM の上で #page の外にあるモーダルには効きません。
Esc キーは、モーダルの要素ではなく document で受けています。フォーカスがモーダルの中になく、ページのどの要素にもない状態(body)のときでも閉じられるようにするためです。
React 19 では true を渡すと属性が付く
開く前と開いたあとの #page の開始タグを、ブラウザで取り出しました。
閉じているとき: <div id="page">
開いているとき: <div id="page" inert="">
dialog の親: body
開いた直後のフォーカス: button「閉じる」
閉じたあと: <div id="page">
true のときは inert="" が付き、false に戻すと属性ごと消えました。MDN の用語集にある、HTML の真偽値の属性の説明のとおり、こうした属性は値ではなく、あるかないかで決まります。dialog の親として出ている body は、幕(.backdrop)の親です。
React 18 では事情が違いました。inert への対応は、React の変更履歴の 19.0.0 に「Add support for inert」として入っています。react-dom/server の renderToStaticMarkup で、渡す値ごとの出力を React 19.3.1 と 18.3.1 で比べました。
react 19.3.1
inert={true} → <div inert=""></div>
inert={false} → <div></div>
inert={""} → <div></div> 警告: Received an empty string for a boolean attribute `inert`. This will treat the attribute as if it were false. Either pass `false` to silence this warning, or pass `true` if you used an empty string in earlier versions of React to indicate this attribute is true.
react 18.3.1
inert={true} → <div></div> 警告: Warning: Received `true` for a non-boolean attribute `inert`.
inert={false} → <div></div>
inert={""} → <div inert=""></div>
React 18 では true を渡すと属性が付かず、空文字列を渡す書き方で付けていました。React 19 で同じ空文字列を渡すと、属性が付かないうえに警告が出ます。React 18 から上げるときは、inert="" を inert={true} に書き換えます。@types/react 19.3.0 では、inert の型は boolean | undefined でした。
後ろにはフォーカスもクリックも届かない
モーダルを開いたまま、Tab キーを7回押して、フォーカスのある要素を順に記録しました。inert を付けない版と比べています。
=== Tab を7回(inert あり) ===
button「閉じる」 → button「保存する」 → body → button「閉じる」 → button「保存する」 → body → button「閉じる」 → button「保存する」
=== Tab を7回(inert なし) ===
button「閉じる」 → button「保存する」 → body → a「ヘルプへのリンク」 → input「後ろの入力欄」 → button「設定を開く」 → button「後ろのボタン」 → button「閉じる」
inert がないと、半透明の幕で覆っていても、フォーカスは後ろのリンクや入力欄へ移りました。inert を付けると、後ろの要素には一度も移りません。ただし、モーダルの中だけを巡回するわけではなく、「保存する」の次は一度 body になりました。inert は後ろを操作できなくするだけで、フォーカスを閉じ込める仕組みではありません。WAI-ARIA のモーダルダイアログのパターンのように中だけを巡回させたいときは、別に作り込みます。
開いたまま、後ろのボタンに focus() を呼んでから Enter キーを押す操作も試しました。
inert あり: フォーカス=button「閉じる」、Enter のあとの回数=0回
inert なし: フォーカス=button「後ろのボタン」、Enter のあとの回数=1回
inert があると、コードから focus() を呼んでもフォーカスは移らず、ボタンも押されませんでした。
クリックは、幕があればそもそも幕に当たります。inert だけの効き目を見るため、幕をなくしてモーダルを右下に置いた版で、後ろのボタンの位置をクリックしました。
inert あり(幕なし): elementFromPoint=div#root、クリック後の回数=0回
比較:閉じているときにクリックした回数= 1回
その位置の要素を document.elementFromPoint() で調べると、ボタンではなく、inert の外にある #root が返りました。クリックはボタンに届かず、回数は 0回のままでした。
下の GIF は、幕のある版で、開いてから Tab キーを押し、後ろのボタンの位置をクリックし、Esc キーで閉じるまでの様子です。この版のクリックは幕に当たって止まります。

画面の下の帯、カーソル、クリックした位置の輪、回数のピンクの強調は、撮影のために足したものです。マウスで開いた直後は、フォーカスの枠が表示されないため、フォーカスの場所は帯の表示で示しています。
支援技術への見え方は、Chromium の DevTools Protocol(Accessibility.getFullAXTree)で、アクセシビリティツリーを取り出して確かめました。見出し・ボタン・リンク・入力欄などの役割を持つ要素のうち、inert を付けたときに残ったのは、モーダルの中の要素だけでした。
=== Chromium のアクセシビリティツリー(inert あり) ===
dialog「設定」、heading「設定」、button「閉じる」、button「保存する」
=== Chromium のアクセシビリティツリー(inert なし) ===
dialog「設定」、heading「メモ帳」、textbox「後ろの入力欄」、heading「設定」、button「閉じる」、button「保存する」、link「ヘルプへのリンク」、button「設定を開く」、button「後ろのボタン」
inert 1つで、後ろの要素へのフォーカス、クリック、アクセシビリティツリーへの表示をまとめて止められます。ただし、フォーカスをモーダルの中だけで巡回させる働きはありません。
モーダル自体は inert の外に描画する
MDN によると、inert の中の要素が、祖先の inert から外れる方法はありません。例外は showModal() で開いた <dialog> だけです。比べるため、createPortal を使わずに、モーダルを <div id="page"> の中にそのまま描画した版も試しました。
開いた直後のフォーカス: body
dialog が inert の中か: true
「閉じる」の位置をクリックしたあと、dialog があるか: true
Tab を押したあとのフォーカス: body
Esc を押したあと、dialog があるか: false
モーダルの中のボタンにもフォーカスが移らず、「閉じる」をクリックしても閉じませんでした。Esc キーで閉じられたのは、document で受けていたからです。inert を付ける要素とモーダルは、DOM の上で兄弟か、別の枝に置きます。この記事のように createPortal で document.body に描画すれば、React のツリーの中のどこでモーダルを書いても、DOM の上では #page の外になります。
<dialog> を showModal() で開く方法なら、ブラウザが後ろを inert と同じように扱うので、自分で inert を付ける必要はありません。こちらは筆者の以前の記事「ReactでHTMLのdialog要素を使い、モーダルを作る方法」で試しました。
フォーカスは inert が外れてから戻す
閉じたときに、開く前のボタンへフォーカスを戻す処理は、inert が外れたあとに動く必要があります。戻し先のボタンも #page の中にあるからです。上のコードでは useEffect のクリーンアップで戻しています。同じ処理を useLayoutEffect に書いた版と比べました。どちらも Esc キーで閉じ、戻し先の focus() が呼ばれた瞬間に、祖先に inert があったかを記録しています。
=== 閉じたあとのフォーカス(useEffect のクリーンアップで戻す) ===
focus() を呼んだとき、#open の祖先に inert があるか: false
Esc で閉じたあと: button「設定を開く」 / <div id="page">
=== 閉じたあとのフォーカス(useLayoutEffect のクリーンアップで戻す) ===
focus() を呼んだとき、#open の祖先に inert があるか: true
Esc で閉じたあと: body / <div id="page">
useLayoutEffect のクリーンアップでは、#page にまだ inert が残っているうちに focus() が呼ばれました。そのため、閉じたあとのフォーカスは body になりました。useEffect のクリーンアップでは inert が外れたあとに呼ばれ、「設定を開く」に戻りました。
フォーカスのある要素の祖先に inert を付けたときの動きも確かめました。
=== フォーカスのある要素の祖先に inert が付いたとき ===
前: button「後ろのボタン」
inert を付けたあと: body
inert を外したあと: body
フォーカスは body に移り、inert を外しても元には戻りませんでした。開く前の要素は自分で覚えておき、閉じたら戻す処理が欠かせません。戻し先のボタンが消えた場合などの詳しい書き方は、筆者の以前の記事「Reactでモーダルを閉じたあと、元のボタンにフォーカスを戻す方法」で試しています。
Playwright のロケーターは inert の要素も見つける
Playwright でテストを書くときは、ブラウザのアクセシビリティツリーと、Playwright のロケーターの結果が一致しない点に注意します。モーダルを開いたまま、後ろのボタンを探しました。
Playwright の getByRole で「後ろのボタン」を探した数: 1
Playwright の ariaSnapshot に「後ろのボタン」があるか: true
page.click: page.click: Timeout 2000ms exceeded. / waiting for element to be visible, enabled and stable
Chromium のツリーからは消えていた後ろのボタンが、Playwright 1.63.0 の getByRole では見つかり、ariaSnapshot() にも出ました。「後ろのボタンが見つからないこと」を getByRole で確かめるテストは、この版では通りません。一方、page.click() は押せる状態になるのを待ち続け、タイムアウトしました。後ろの画面を操作できないことをテストで確かめるなら、ロケーターの結果ではなく、最初の節のように #page に inert 属性が付いているかを見るほうが確実です。
inert を使うときのポイント
inert は MDN の browser-compat-data の記録で Chrome 102、Firefox 112、Safari 15.5 から使えます。React では次の点を押さえれば、外側を操作できないモーダルになります。
- React 19 では
inert={open}と真偽値を渡す。React 18 のころのinert=""は、React 19 では属性が付かない inertを付けるのは後ろの画面を包む要素で、モーダル自体はcreatePortalなどで DOM の上でその外に置くinertはフォーカスを閉じ込めないので、Tab キーで中だけを巡回させたいときは別に作る- 開く前の要素を覚えておき、
inertが外れたあと(useEffectのクリーンアップなど)にフォーカスを戻す





