Reactのinert属性で、モーダルの後ろを操作できなくする方法

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

自作のモーダルを開いている間は、後ろの画面を包む要素に 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 の直下に描画します。

tsx
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 の開始タグを、ブラウザで取り出しました。

text
閉じているとき: <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 で比べました。

text
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.
text
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 を付けない版と比べています。

text
=== 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 キーを押す操作も試しました。

text
inert あり: フォーカス=button「閉じる」、Enter のあとの回数=0回
inert なし: フォーカス=button「後ろのボタン」、Enter のあとの回数=1回

inert があると、コードから focus() を呼んでもフォーカスは移らず、ボタンも押されませんでした。

クリックは、幕があればそもそも幕に当たります。inert だけの効き目を見るため、幕をなくしてモーダルを右下に置いた版で、後ろのボタンの位置をクリックしました。

text
inert あり(幕なし): elementFromPoint=div#root、クリック後の回数=0回
比較:閉じているときにクリックした回数= 1回

その位置の要素を document.elementFromPoint() で調べると、ボタンではなく、inert の外にある #root が返りました。クリックはボタンに届かず、回数は 0回のままでした。

下の GIF は、幕のある版で、開いてから Tab キーを押し、後ろのボタンの位置をクリックし、Esc キーで閉じるまでの様子です。この版のクリックは幕に当たって止まります。

メモ帳の画面で「設定を開く」を押すと、画面が灰色の幕で覆われ、上に設定のモーダルが出る。Tab キーを押すとフォーカスは「保存する」、ページの外、「閉じる」、「保存する」と移り、後ろの要素には移らない。後ろのボタンの位置を2回クリックしても、幕に当たるため回数は0回のまま。Esc キーで閉じると、フォーカスは「設定を開く」に戻る
モーダルを開いてから、Tab キー、後ろのボタンのクリック、Esc キーで閉じるまでの動き

画面の下の帯、カーソル、クリックした位置の輪、回数のピンクの強調は、撮影のために足したものです。マウスで開いた直後は、フォーカスの枠が表示されないため、フォーカスの場所は帯の表示で示しています。

支援技術への見え方は、Chromium の DevTools Protocol(Accessibility.getFullAXTree)で、アクセシビリティツリーを取り出して確かめました。見出し・ボタン・リンク・入力欄などの役割を持つ要素のうち、inert を付けたときに残ったのは、モーダルの中の要素だけでした。

text
=== 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"> の中にそのまま描画した版も試しました。

text
開いた直後のフォーカス: 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 があったかを記録しています。

text
=== 閉じたあとのフォーカス(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 を付けたときの動きも確かめました。

text
=== フォーカスのある要素の祖先に inert が付いたとき ===
前: button「後ろのボタン」
inert を付けたあと: body
inert を外したあと: body

フォーカスは body に移り、inert を外しても元には戻りませんでした。開く前の要素は自分で覚えておき、閉じたら戻す処理が欠かせません。戻し先のボタンが消えた場合などの詳しい書き方は、筆者の以前の記事「Reactでモーダルを閉じたあと、元のボタンにフォーカスを戻す方法」で試しています。

Playwright のロケーターは inert の要素も見つける

Playwright でテストを書くときは、ブラウザのアクセシビリティツリーと、Playwright のロケーターの結果が一致しない点に注意します。モーダルを開いたまま、後ろのボタンを探しました。

text
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 のクリーンアップなど)にフォーカスを戻す

​Posted by 86