アクセシブルなモーダルの構築:ネイティブ `<dialog>` か、ライブラリか?
アクセシブルなモーダルはネイティブ<dialog>かライブラリか。フォーカス管理、inert背景、ESC、スクロールロック、focus trapの要否を整理。
2026年においては、showModal() を使ったネイティブ <dialog> 要素をデフォルトの選択肢とすべきです。フォーカスの封じ込め、ESCキーによるクローズ、トップレイヤーへのレンダリング、背景スクロールの防止、そして JavaScript ゼロでの暗黙的な aria-modal が得られます。Radix、React Aria、Headless UI、a11y-dialog といったライブラリに頼るのは、複雑なスタッキング、高度なアニメーション要件、デザインシステムとの組み合わせ、あるいは実際に計測して確認したネイティブの不足点がある場合に限るべきです。
この結論は、長年の通説を覆すものです。従来の標準的なアドバイス——モーダルをポータルで描画し、フォーカスを手動でトラップし、aria-hidden で背景を隠し、ESCキーのリスナーを手動で設定する——は、<dialog> と inert が登場する以前に「しなければならなかったこと」を説明していたものです。<dialog> 要素は2022年3月以降、すべての主要ブラウザでBaselineとなっています。そのため、これらの実装の大部分はブラウザのデフォルト動作となりました。本記事では、ネイティブとライブラリの選択を具体的な基準で整理し、多くのガイドが古い情報を繰り返しているフォーカストラップの細かい点を正確に解説し、バニラJSとReactで検証済みの最小実装を提供し、正しいARIAだけでは解決できないスクリーンリーダーの現実について説明します。
重要なポイント
showModal()で開いたネイティブ<dialog>は、ダイアログをブラウザのトップレイヤーに配置し、ページの残りの部分をinertにします。そのため、JavaScriptによるフォーカストラップなしに、ページレベルのモーダル内にフォーカスが封じ込められます。- ネイティブ
<dialog>に JavaScript のフォーカストラップを追加してはいけません。Tabキーで到達できるのはブラウザ自身のクローム(アドレスバー、タブ、拡張機能)のみであり、マウスユーザーと同様に、キーボードユーザーもそこに到達できるべきです。 - 手動のフォーカストラップは、
<dialog>を使わないカスタムモーダルに属するものです。そのようなモーダルを構築する場合、背景へのinert属性の管理も自分で行う必要があります。 - ネイティブ実装で最もよくある落とし穴は気づきにくいものです。最初のフォーカス可能なコントロールがダイアログのDOM下部にある場合、
showModal()がそこにフォーカスを移動させ、モーダルが下部にスクロールした状態で開きます。ソース上でクローズボタンを見出しの後に配置することで修正できます。 - 正しいARIAは必要条件ですが、十分条件ではありません。2026年時点でも、SafariとVoiceOverの組み合わせでは、
aria-modalダイアログ内の静的コンテンツに到達できない場合があります。実際のスクリーンリーダーでテストしてください。
アクセシブルなモーダルが実際に満たすべき要件
アクセシブルなモーダルには、5つの必須動作があります。開いたときに自身の中にフォーカスを移動させること、背景を到達不可かつ非インタラクティブにすること、キーボード(ESC)と視覚的なコントロールで閉じられること、閉じたときにトリガー要素にフォーカスを戻すこと、そしてスクリーンリーダーがアナウンスできるようにアクセシブルな名前を持つダイアログロールを公開することです。これらはWCAGの達成基準——フォーカス順序(2.4.3)、キーボード操作性(2.1.1、2.1.2)、名前・役割・値(4.1.2)——に直接対応しています。WCAG 2.2のクイックリファレンスには残りの基準が記載されていますが、この短いリストがモーダルの成否を左右します。
これらが失敗しがちな理由は、手作りの position: fixed モーダルでは、それぞれが独立した仕組みとして実装されるからです。フォーカスの移動、フォーカスの復元、ESCリスナー、背景の非表示化、ロールの設定を個別にスクリプト化する必要があり、そのどれか一つが静かに壊れるだけで、マウスユーザーには正常に見えても、他のすべてのユーザーにとっては壊れたモーダルになります。ネイティブ要素を支持する理由は、それらの仕組みの大部分を1つのDOM呼び出しに集約できることです。
<dialog> と showModal() が無償で提供するもの——そして提供しないもの
Discover how at OpenReplay.com.
<dialog> に対して showModal() を呼び出すと、その呼び出し自体以外のJavaScriptなしで以下が得られます。ダイアログへの初期フォーカスの移動、ブラウザによるEscキーでの閉じる動作、z-index やポータル処理を回避するトップレイヤーへのレンダリング、::backdrop でスタイリングできるバックドロップ、aria-modal="true" を持つ暗黙の role="dialog"、そしてTabキーでダイアログ背後のページコンテンツに到達できなくなる inert 背景です。close() を呼ぶと、ダイアログを開いた要素にフォーカスが復元されます。U.S. Web Design Systemのモーダルを <dialog> ベースに作り直したJared Cunhaは、リライトによってJavaScriptが約400行と4つのユーティリティから約38行に削減されたと報告しています——以前はスクリプトで実装していたアクセシビリティ動作が、今やブラウザのデフォルトになったのです。
提供されないものと、その対処法:
| 落とし穴 | 何が起きるか | 対処法 |
|---|---|---|
| 最初のフォーカス可能要素がDOMの下部にある | showModal() がそこにフォーカスし、ダイアログが下部にスクロールした状態で開く | ソース順でクローズボタンを見出しの後に配置するか、意図的に初期フォーカスを設定する |
autofocus 属性 | 一部のデスクトップブラウザでは初期フォーカスの移動が不安定 | showModal() の後にJSでフォーカスを設定する |
| バックドロップクリック | ネイティブダイアログはデフォルトで外側クリックによる閉じる動作をしない | event.target === dialogEl となるクリックをリッスンする |
| 背景スクロール | ダイアログの背後のページが引き続きスクロールできる | 明示的にスクロールをロックする(後述) |
スクロールが下部に移動する動作は特定のライブラリの癖ではありません——W3C ARIA APGのモーダルダイアログの例では、ダイアログコンテンツの末尾付近の要素に初期フォーカスを置くと、ダイアログが見えない位置にスクロールして開く可能性があると明記されています。最もシンプルな修正はソース順です。見出しを最初に、クローズボタンをその後に配置することで、スクリーンリーダーがコントロールより先にタイトルをアナウンスし、ダイアログが上部から開くようになります。
スクロールロックには、スクロール位置をキャプチャし、bodyをロックし、閉じたときに復元します:
:root:has(dialog[open]) {
overflow: hidden;
scrollbar-gutter: stable; /* スクロールバーが消えたときのレイアウトのずれを防ぐ */
}
scrollbar-gutter: stable はスクロールバーの幅を確保し、スクロールロック時にページが横にずれないようにします。iOS Safariでは、bodyへの overflow: hidden が効かないことで知られています。クロスプラットフォームでの信頼性を高めるには、window.scrollY をキャプチャし、負の top 値で position: fixed を適用する方法がより確実です。
フォーカストラップの問題、決着:ネイティブダイアログにはトラップ不要
showModal() で開いたネイティブ <dialog> に JavaScript のフォーカストラップを追加してはいけません。ブラウザがすでにトップレイヤーに配置し、残りのページを inert にしているため、フォーカスは封じ込められています——Tabキーで到達できるのはブラウザ自身のクローム(アドレスバー、タブ、拡張機能)のみであり、マウスユーザーと同様に、キーボードユーザーもそこに到達できるべきです。これはモーダルのチュートリアルで最も繰り返される時代遅れのアドバイスであり、ネイティブ要素には適用できません。
この根拠は、著名なアクセシビリティの専門家たちの見解に基づいており、Zell LiewがCSS-Tricksの「There is No Need to Trap Focus on a Dialog Element」にまとめています。Scott O’Haraの立場は、WCAGはフォーカストラップを規範的に要求していないというものです。トラップの推奨は、inert と <dialog> が広くサポートされる以前に書かれた参考情報的なガイダンスに由来しており、当時はスクリプトによるダイアログにおいて、外部要素をタブ不可にするよりトラップの方が容易だったためです。Léonie Watsonの見解は、ユーザーはダイアログ内でも通常のページと同じ操作オプションを持つべきであり、ブラウザのコントロールにも引き続きアクセスできるべきだというものです。W3C APA Working Groupの見解は、ネイティブダイアログの動作をそのまま維持すべきというものであり、その理由の一つとして、ブラウザクロームへのタブ移動がキオスクや制限された環境での脱出手段となることが挙げられています。
これは現在進行中の真剣な議論であり、決着した問題ではありません。前述のUSDWS作業を行ったJared Cunhaは、ネイティブ要素にもフォーカストラップ関数を追加しています。その理由は実践的な慎重さによるものです。しかし、規範的な解釈の重みはネイティブ要素に対するトラップ不要の立場にあり、トラップを追加することはキーボードユーザーがデフォルトで持つ機能を積極的に取り除くことになります。
このルールを完成させる注意点:手動フォーカストラップは <dialog> を使わないカスタムモーダルに属するものです——そのようなモーダルを構築する場合、背景への inert の管理も自分で行う必要があります。「常にフォーカスをトラップせよ」というアドバイスは一般的には間違っていません。封じ込めをすでに処理している要素に適用した場合に間違いとなるのです。
最小限の正しいネイティブ <dialog> 実装
以下は、追加する価値のある3つの拡張——バックドロップクリックによるクローズ、モーション設定を考慮したアニメーション、そしてそれ以外は何もない——を含むバニラ版です。フォーカスの復元はすでに close() が処理しています。
<button id="open">ダイアログを開く</button>
<dialog id="dlg" aria-labelledby="dlg-title">
<h2 id="dlg-title">変更の確認</h2>
<p>編集内容はすぐに適用されます。</p>
<form method="dialog">
<button value="cancel">キャンセル</button>
<button value="confirm">確認</button>
</form>
</dialog>
const dlg = document.getElementById('dlg');
document.getElementById('open').addEventListener('click', () => dlg.showModal());
// バックドロップクリックで閉じる(ネイティブはこれを行わない)
dlg.addEventListener('click', (e) => {
if (e.target === dlg) dlg.close();
});
aria-labelledby は表示されている見出しを参照し、ダイアログにアクセシブルな名前を付与します。<form method="dialog"> はサブミット時にダイアログを閉じ、押されたボタンを dlg.returnValue に記録します——追加のJSは不要です。バックドロップのチェックが機能するのは、::backdrop をクリックすると <dialog> 要素自体へのクリックとして登録される一方、コンテンツのクリックは子要素に登録されるためです。
トップレイヤーと戦わずにアニメーションを実装し、モーション設定を尊重します:
dialog {
opacity: 0;
transition: opacity 0.2s, overlay 0.2s allow-discrete, display 0.2s allow-discrete;
}
dialog[open] { opacity: 1; }
@starting-style {
dialog[open] { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
dialog { transition: none; }
}
@starting-style と transition-behavior: allow-discrete は、トップレイヤーに出入りする要素のエントリートランジションを機能させるために必要です。prefers-reduced-motion ブロックはWCAG 2.3.3を満たします。
React版では、命令的なメソッドを呼び出すために useRef を使用し、SSRセーフなラベルの関連付けのために useId を使用します:
import { useRef, useEffect, useId } from 'react';
function Dialog({ open, onClose, title, children }) {
const ref = useRef(null);
const titleId = useId();
useEffect(() => {
const el = ref.current;
if (open) el.showModal();
else el.close();
}, [open]);
return (
<dialog
ref={ref}
aria-labelledby={titleId}
onClose={onClose}
onClick={(e) => { if (e.target === ref.current) ref.current.close(); }}
>
<h2 id={titleId}>{title}</h2>
{children}
</dialog>
);
}
ネイティブの close イベント(ESC、バックドロップハンドラー、またはフォームのサブミットによって発火)が onClose を駆動し、ReactのステートをDOM要素と同期させます。破壊的な確認には、デフォルトの代わりに role="alertdialog" を使用し、バックドロップクリックでは閉じないようにしてください。
ネイティブ <dialog> とライブラリの比較:2026年の意思決定マトリクス
以下を参考に判断してください。パッチレベルは変動するため、統合時にnpmで最新バージョンを確認してください。
| オプション | 優位な場面 | トレードオフ | 現在のバージョン |
|---|---|---|---|
ネイティブ <dialog> | ほとんどのモーダル;JSの依存関係ゼロと組み込みのa11yが必要な場合 | アニメーション/スタッキングの制御が少ない;バックドロップクローズは自分で実装 | 2022年3月以降Baseline |
| a11y-dialog(バニラ) | テスト済みのラッパーとイベントが必要なバニラ/非Reactプロジェクト | aria-modal に依存;モバイルATの既知の制限あり | 8.1.5 |
| Radix / shadcn(React) | デザインシステムチーム;コンポーザブルなプリミティブ;shadcnワークフロー | プリミティブレイヤーとバンドルサイズが増加 | 統合 radix-ui 1.6.0 |
| React Aria(React) | AdobeのA11y動作保証が必要なA11yクリティカルなアプリ | APIが複雑;意見が強い | @react-aria/dialog 3.5.34 |
| Headless UI(React) | マッチしたスタイルなしのプリミティブが必要なTailwindプロジェクト | Reactのみ;カスタム(非<dialog>)実装 | 2.2.10 |
| Base UI(React) | MUIチームによるモダンなプリミティブを求める新規プロジェクト | エコシステムが若い | 安定版v1.0(2025年12月) |
a11y-dialogの最新バージョンは8.1.5であり、独自の既知の問題ドキュメントによると、WAI-ARIA 1.1の aria-modal に依存しており、特定のモバイル支援技術でのサポートが十分ではありません——aria-hidden ヘルパーと組み合わせることが文書化された緩和策です。
React用Headless UIはバージョン2.2.10で、2026年4月7日に公開されました。Headless UIのドキュメントによると、そのDialogはカスタムのdivベースのコンポーネントであり、フォーカスを内部に移動してトラップします——これはネイティブ <dialog> ではないため、正しい動作です。React Ariaのドキュメントによると、フォーカスはマウント時にダイアログに移動し、アンマウント時にトリガーに復元され、開いている間は封じ込められます。注意点として、React AriaのドキュメントにはHTMLの <dialog> 要素がまだ広くサポートされていないという記述がありますが、2022年以降のBaseline状態を考えると、この情報は古くなっています。
ヘッドレスライブラリの状況は2025年後半から2026年初頭にかけて大きく変化しました。Radix、Floating UI、Material UIの出身者によって構築されMUIチームが管理するBase UIが、2025年12月に安定版v1.0に到達しました。また、shadcn/uiは2026年2月にRadixの使用を統合 radix-ui パッケージに統合しました。よく混同される事実として、RadixのオーナーであるModulzは2022年にWorkOSに買収されています——これは4年前の話であり、最近のニュースではありません。真の最近の変化は、代替プリミティブレイヤーとしてのBase UIの台頭です。ほとんどのチームは引き続きshadcn/uiを通じてRadixを使用しています。
<dialog> を使えない場合:カスタムモーダルのチェックリスト
非モーダルの共存ダイアログ、複雑なスタッキング、またはレガシーの制約により、カスタムモーダルを構築しなければならない場合、ネイティブ要素が処理していたすべての責任を自分で負うことになります。最低限:
- 開いたときにフォーカスを移動し、閉じたときに復元する;
- アプリの残りの部分に
inert属性を適用する(aria-hiddenより堅牢であり、inertは要素をタブ順序からも除外する); - フォーカス可能な子要素に対してTabとShift+Tabを循環させる実際のフォーカストラップを実装する;
- ESCキーのkeydownハンドラーを追加する;
- 背景スクロールをロックする;
aria-modal="true"とアクセシブルな名前を持つrole="dialog"/alertdialogを設定する。
これが——そしてこれだけが——「常にフォーカスをトラップせよ」というアドバイスが正しい場合です。
このようなカスタムモーダルのアクセシビリティバグは、本番環境のセッションリプレイが明らかにするものです。実際のユーザーが経験したフォーカスとキーボードの軌跡を再現できるからです。キーボードユーザーがカスタムモーダルから直接背景コンテンツにTabで移動する様子を見ることができます——inert の欠如やトラップの破損の明確なシグネチャです。モーダルが閉じた後にフォーカスがどこにも移動しない様子も見えます——オープナーの参照が保存されていなかった証拠です。ESCハンドラーなしでカスタムビルドがリリースされ、キーボードでまったく閉じられないモーダルも見えます。これらは監査が見逃し、リプレイが捉える失敗パターンです。
スクリーンリーダーの現実:正しいARIAだけでは不十分
正しいARIAは必要条件ですが、十分条件ではありません。2026年時点でも、SafariとVoiceOverの組み合わせでは、aria-modal ダイアログ内の静的な非フォーカス可能コンテンツが仮想カーソルで到達できない状態になる場合があります——これはDequeの2025年12月のARIAモーダルダイアログに関する記事で裏付けられた既知のWebKitの問題です。実際の影響として、完璧にマークアップされたダイアログでも、VoiceOverユーザーにはその本文テキストが隠れてしまう可能性があります。テストしてください。仮定しないでください。
さらに、NVDAはすべての設定でダイアログロールをアナウンスしない場合があり、TalkBackは絶対配置されたコンテンツを見逃す場合があります。重要なのはマークアップではなくプロセスです。少なくとも2つのスクリーンリーダー/ブラウザの組み合わせ——最低限VoiceOver/SafariとNVDA/FirefoxまたはChrome——でテストしてください。自動化された監査は属性が存在することを確認するだけであり、実際の支援技術がコンテンツに到達できることは確認しないからです。
まとめ
まずネイティブ <dialog> を選択し、4つの小さな拡張(バックドロップクローズ、意図的な初期フォーカス、スクロールロック、モーション設定を考慮したアニメーション)を実装し、不要なフォーカストラップを追加する衝動を抑えてください。Radix、React Aria、Headless UI、a11y-dialog、またはBase UIを導入するのは、コンポーザビリティ、アニメーション、スタッキングといった具体的な要件が、アクセシビリティを標準で提供するゼロ依存の要素を上回る場合だけにしてください。そして、スクリーンリーダーを開いて、監査では確認できないことを確かめてください——実際のユーザーがすべての文字に到達できるかどうかを。
FAQ
form method=dialog属性を使用すると、JavaScriptなしでネイティブのdialog要素が動作しますか?
open属性を使用すればダイアログをJavaScriptなしで開くことができますが、その場合はトップレイヤーなし、inert背景なし、フォーカスの封じ込めなしの非モーダルダイアログとしてレンダリングされます。モーダルの動作には、宣言的にトリガーできないJavaScriptでのshowModal()の呼び出しが必要です。ただし、ダイアログ内にmethod='dialog'を持つformを配置することで、追加のJavaScriptなしにダイアログを閉じることができます。そのフォームをサブミットするとダイアログが閉じ、押されたボタンがreturnValueプロパティに記録されます。
モーダルにおけるrole=dialogとrole=alertdialogの違いは何ですか?
ネイティブdialog要素のデフォルトであるrole='dialog'は、ユーザーがコンテンツを閲覧したり入力したりする一般的なモーダルに使用されます。一方、role='alertdialog'は、破壊的なアクションの確認など、応答が必要な緊急性の高い割り込みメッセージを示します。スクリーンリーダーはalertdialogのコンテンツをより強調してアナウンスします。alertdialogモーダルでは、バックドロップクリックや軽率な閉じる操作を許可しないでください。このパターンは、ユーザーが誤ってダイアログを閉じるのではなく、意図的な選択をすることを前提としているためです。
なぜダイアログが上部ではなく下部にスクロールした状態で開くのですか?
これは、ダイアログのDOM内で最初のフォーカス可能な要素がコンテンツの末尾付近にある場合に発生します。showModal()が実行されると、ブラウザはその最初のフォーカス可能なコントロールにフォーカスを移動させ、それが下部にある場合、ダイアログはそこにスクロールした状態で開きます。W3C ARIA Authoring Practices Guideも同じ動作を文書化しています。ソース順を調整して見出しを最初のインタラクティブなコントロールより前に配置することで修正できます。例えば、クローズボタンをタイトルの前ではなく後に配置します。
open属性セレクターの代わりに:open疑似クラスで開いているダイアログをスタイリングできますか?
:open疑似クラスで開いているダイアログをスタイリングできますが、Safari 26.5が搭載されたのは2026年5月であり、Baselineとして新たに利用可能になったばかりです。広くサポートされているわけではなく古いブラウザバージョンでは対応していないため、より安全なクロスブラウザのセレクターは、2022年3月に要素がBaselineに達して以来機能しているdialog[open]属性セレクターです。プライマリアプローチとして属性セレクターを使用し、サポートが広まるまで:openはプログレッシブエンハンスメントとして扱ってください。
Headless UIはネイティブのdialog要素を使用していますか?また、フォーカストラップを使用すべきですか?
Headless UIのDialogコンポーネントはネイティブHTMLのdialog要素ではなく、カスタムのdivベースの実装です。そのため、ユーザーがフォーカス可能な要素を循環する際に、正しくフォーカスを内部に移動してトラップします。これは、カスタムの非dialogモーダルはフォーカスをトラップし、背景のinertを自分で管理しなければならないというルールと一致しています。フォーカストラップを取り除くべきではありません。フォーカストラップ不要のガイダンスは、ブラウザがすでにページへのフォーカスを封じ込めているshowModal()で開いたネイティブdialog要素にのみ適用されます。
Truly understand users experience
See every user interaction, feel every frustration and track all hesitations with OpenReplay — the open-source digital experience platform. It can be self-hosted in minutes, giving you complete control over your customer data.
Star on GitHub12k