Patterns

dialog要素を使用したモーダルウィンドウの実装例

この記事は2024年4月時点の実装です。その後、開閉をbutton要素のcommand属性とcommandfor属性(Invoker Commands)で、オーバーレイのクリックで閉じる動作をdialog要素のclosedby属性で書けるようになり、JavaScriptを書かずにモーダルを実装できるようになりました。新しい実装方法は「JavaScriptを書かない2025年のモーダルの実装方法」で紹介しています。

Invoker CommandsはChrome 135、Firefox 144、Safari 26.2でサポートされ、2025年12月にBaseline Newly available(新しく利用可能)になりました。closedby属性はChrome 134とFirefox 141でサポートされていますが、2026年11月時点でSafariは未対応です。また、当ブログのハンバーガーメニューも現在はInvoker Commandsとclosedby属性で実装しています。

dialog要素を使用したアクセシブルなモーダルウィンドウの実装メモです。このブログのハンバーガーメニューで使われている実装と同じものになります。

dialog要素は現在全てのモダンブラウザでサポートされているため、iOS Safariをどこまで対応するかに依りますが実務で使用しても差し支えないでしょう。

<dialog>

広く利用可能 2024年9月から(新しく利用可能になったのは2022年3月)

執筆時点(2024年4月)は「新しく利用可能」でした

Chrome
37
Edge
79
Firefox
98
Safari
15.4
iOS Safari
15.4

web-features 3.44.0 のデータ

webstatus.dev で見る

コードとサンプル

モーダルウィンドウの実装例

JavaScriptの実装はGitHub Gistに纏めています。コードにはTypeScriptを使用していますので、TypeScriptを利用していないWeb制作現場で使用する場合はChatGPTなどに依頼してJSファイルに変換してください。

initializeModal

initializeModal. GitHub Gist: instantly share code, notes, and snippets.

gist.github.com

dialog要素について

dialogは対話的コンポーネント(ダイアログボックスやモーダルウィンドウ等)を示すHTMLの要素です。

かつてはMicromodal.js(新しいタブで開く)などのアクセシブルなJSライブラリを利用してモーダルウィンドウを実装するのが主流でしたが、現在ではdialog要素を使用すればウェブ標準かつアクセシブルなモーダルウィンドウを低コストで実装できます。

モーダルを開閉するだけならこれだけで良い
const modal = document.getElementById('modal')
const openTrigger = document.getElementById('openModal')
const closeTrigger = document.getElementById('closeModal')

openTrigger.addEventListener('click', () => {
	modal.showModal()
})

closeTrigger.addEventListener('click', () => {
	modal.close()
})

dialogで実装できるものはshowModal()メソッドを利用する「モーダル」と、show()メソッドを利用する「モードレス」の2種類があります。

モーダルモードレス
ユーザーのアクションを制限して、
そのモードから出るまでは他の作業をさせないようにする状態
ユーザーのアクションを制限せず、
他の作業も並行して行えるようにする状態

今回はshowModal()メソッドを利用する「モーダル」の実装例となりますので、dialog要素を使用したモードレスの実装については省略します。

モードレスを実装する場合はウェブ標準かつJS無効環境でも動作するポップオーバー APIも利用できます。

ポップオーバー APIはFirefox 125のリリースにより全てのモダンブラウザで利用が可能となりますので、将来的にはポップオーバー APIを利用も検討することをおすすめします。

dialog要素を使う際の注意点としては次のとおりです。

  • dialog要素でtabindex属性を使用するのは禁止です。このことはMDNのドキュメント(新しいタブで開く)でも警告として示されています。
  • dialog要素の表示・非表示の切り替えにはshowModal()またはshow()メソッドを使用してください。open属性の切り替えでdialog要素の表示してもそれはモーダルとしては扱われません。

また、dialog要素を使用したモーダルの実装においてはJS無効環境の考慮はしないこととします。理由としては以前紹介したdetailsやhidden="until-found"を使用した実装とは違い、dialog要素はJSでの操作が前提とされているためです。dialogのdisplayの値を変えれば非モーダルとして表示することはできますが、open属性が付与されていない場合にはユーザーに表示するべきではないとMDNに記載がされています。

InvokersがサポートされればJS無効環境でもdialog要素の取り扱いができるようにはなりますが、現時点では全ブラウザでサポートされていないため考えないこととします。

ハンバーガーメニューが機能しなくてもフッターのメニューからアクセスできるようにするなど、Webサイトの設計をする際は可能であればモーダルウィンドウが存在しなくても重要なコンテンツにアクセスできるように心がけたほうが良いでしょう。

dialog要素に備わっている機能・備わっていない機能

モーダルウィンドウを実装する際、求められる要件は次のとおりです。

dialogのモーダルには「最上位レイヤー(新しいタブで開く)で表示する」「背面のコンテンツをinert(新しいタブで開く)(不活性化)する」「Escキーでモーダルウィンドウが閉じる」機能が標準で備わっています。よって上記項目のうち、次の項目に関してはdialogを利用することで自然と解決することができます。

ただし、後述するdialogに標準で備わっていない機能を実装する場合、デフォルトのEscキーで閉じる機能を使用すると独自の実装が機能しなかったり、スクロールが固定されたままになるなどの不具合が発生してしまうので、原則的にはkeydownイベントでデフォルトの動作は無効化しつつEscキーを押下したら独自のcloseModal関数を発火させることを推奨します。

dialogに標準で備わっていない次の項目は各々で実装を行う必要があります。

dialog要素のマークアップ

マークアップの実装例は次のとおりです。

<button type="button" data-modal-open="modal1">モーダルを開く</button>

<dialog id="modal1" aria-labelledby="headline1" aria-describedby="desc1" autofocus>
	<div class="container">
		<h2 id="headline1">モーダルのタイトル</h2>
		<p id="desc1">モーダルの説明文</p>
	</div>
	<button type="button" aria-labelledby="close1" data-modal-close>
		<span id="close1" style="display:none">モーダルを閉じる</span>
	</button>
</dialog>

先述したようにJS無効環境は考慮しないので、モーダルを開くor閉じるトリガーとなる要素はbuttonで実装します。

開くトリガーの実装

開くトリガーとなる要素にはdata-modal-open属性を付与して、値にはターゲットとなるdialog要素のidを紐づけます。

<button type="button" data-modal-open="modal1">モーダルを開く</button>

<dialog id="modal1" aria-labelledby="headline1" aria-describedby="desc1" autofocus>...</dialog>

トリガーの値とdialog要素のidが一致する場合にモーダルウィンドウを開くようにすることで、ページ内に異なるコンテンツを持つモーダルウィンドウが複数存在する場合の対応も行っています。

const initializeModal = (modal: HTMLDialogElement): void => {
  // ...
  const openTriggers = document.querySelectorAll(`[data-modal-open="${modal.id}"]`) as NodeListOf<HTMLButtonElement>

  // ...
  openTriggers.forEach((trigger) => {
    trigger.addEventListener('click', (event) => handleOpenTriggerClick(event, modal, trigger), false)
  })
}

const handleOpenTriggerClick = (event: Event, modal: HTMLDialogElement, trigger: HTMLButtonElement): void => {
  event.preventDefault()
  openModal(modal)
}

aria-labelledby属性でのラベリング

どのモーダルウィンドウが表示されているかを支援技術に伝えるためにdialog要素にはaria-labelもしくはaria-labelledby属性を付与します。

今回のケースであればモーダルウィンドウには見出し要素を含んでいるため、見出し要素を参照したaria-labelledby属性でラベリングを行います。

<dialog id="modal1" aria-labelledby="headline1" aria-describedby="desc1" autofocus>
	<div class="container">
		<h2 id="headline1">モーダルのタイトル</h2>
		...
	</div>
	...
</dialog>

モーダルウィンドウが見出し以外の説明文となるテキストを含んでいる場合にはaria-describedby属性を使用して関連付けを行うこともできます。

<dialog id="modal1" aria-labelledby="headline1" aria-describedby="desc1" autofocus>
	<div class="container">
		<h2 id="headline1">モーダルのタイトル</h2>
		<p id="desc1">モーダルの説明文</p>
	</div>
	...
</dialog>

autofocus属性の指定

dialog要素内には最初にフォーカスが当たるべき要素にautofocus属性を付与します。

<dialog id="modal1" aria-labelledby="headline1" aria-describedby="desc1" autofocus>...</dialog>

原則的にshowModal()した場合はdialog要素内の最初のfocusableな要素にフォーカスが移動しますが、autofocus属性を指定することでユーザーの利便性を考えた際にベターであろう位置にフォーカスを移動させることができます。

今回の場合はautofocus属性はdialog要素に指定しています。

dialog要素へのautofocus属性の指定はあくまで適切なフォーカス対象が存在しない場合の最終手段として捉えてください。

また、将来的にはモーダルdialogの場合はautofocus属性が必須になるとのことです。

ただし、tabindex属性が指定されていないdialog要素へのオートフォーカスは現時点で全ブラウザで対応されておりません。 先述した通りdialog要素でのtabindex属性の使用は禁じられているので、釈然としない気持ちは残りますが一旦はこのまま実装することとします。

閉じるトリガーの実装

閉じるトリガーとなる要素にはdata-modal-close属性を付与します。また、buttonの中身がアイコンのみの場合は支援技術に伝わるようにラベリングを行ってください。

<button type="button" aria-labelledby="close1" data-modal-close>
	<span id="close1" style="display:none">モーダルウィンドウを閉じる</span>
</button>

aria-labelだと一部のブラウザで機械翻訳がされず、visually hiddenしたテキストだとページ内検索で引っかかってしまうため今回のケースではdisplay:noneしたテキストを参照したaria-labelledby属性でラベリングを行っています。

ラベリングの方法に問題がないかMarkuplint開発者の平尾さんに確認いただいたところ「あり」だと回答いただいたので、今後はハンバーガーボタンのラベルのように選択してコピーしても嬉しくないようなテキストの場合はこの方法も考慮する予定です。

dialog要素のCSS

dialog要素はUserAgentでスタイリングが施されていますが、UAデフォルトのスタイルは非常に癖が強いです。

UAのスタイル
dialog {
	display: none;
	position: absolute;
	inset-inline-start: 0px;
	inset-inline-end: 0px;
	width: fit-content;
	height: fit-content;
	background-color: canvas;
	color: canvastext;
	margin: auto;
	border-width: initial;
	border-style: solid;
	border-color: initial;
	border-image: initial;
	padding: 1em;
}

dialog[open] {
	display: block flow;
}

dialog:-internal-dialog-in-top-layer {
	position: fixed;
	inset-block-start: 0px;
	inset-block-end: 0px;
	max-width: calc(100% - 2em - 6px);
	max-height: calc(100% - 2em - 6px);
	user-select: text;
	visibility: visible;
	overflow: auto;
}

UAスタイルシートをリセットする

基本的にこのスタイルを活かしてデザインを反映するのは厳しい印象なので、スタイリングの邪魔になるプロパティは予めリセットしておきます。

リセットの例としては次のような指定です。

リセットCSS例
:where(dialog) {
	width: unset;
	max-width: unset;
	height: unset;
	max-height: unset;
	padding: unset;
	color: unset;
	background-color: unset;
	border: unset;
	overflow: unset;
}
  • max-widthおよびmax-heightが指定されていると画面いっぱいのモーダルウィンドウが実装できないのでunsetを指定して初期値のnoneとします。
  • paddingおよびborderもデフォルトのスタイルは不要なのでunsetします。
  • colorおよびbackground-colorもデフォルトのスタイルは不要なのでunsetします。この場合はcolorは継承ありなのでinherit、background-colorは継承なしなので初期値のtransparentとなります。
  • height:fit-contentはSafariで意図しない動作が起こり得る(新しいタブで開く)のでunsetで初期値のautoにしておいたほうがいいでしょう。
  • overflow:autoだと閉じるボタンをオーバーレイ上に表示させるといったデザインがやりにくかったり、何かと不要になる場面が多かったのでunsetで初期値のvisibleにしています。スクロールさせたい場合は子孫要素でoverflow:autoを指定するほうがオススメです。

スクロールする要素には overscroll-behavior:contain を指定する

dialog要素内でコンテンツをスクロールさせて表示させたい場合、overscroll-behavior:containも指定することを推奨します。

背面のスクロールの抑制は別で行うものの、スクロールが可能な際にラバーバンド効果(スワイプで更新や履歴を戻る処理をする際のバウンス効果)が有効なままだと暴発して操作がしにくい印象となります。

🙆‍♂ Recommended
.container {
	block-size: 100%;
	overflow: auto;
	overscroll-behavior: contain;
}

「端っこ」におけるスクロールの挙動を制御する overscroll-behavior プロパティ

※この記事の内容は、まだブラウザに実装されていない内容を含みます。また、勧告前の仕様について言及しているため、最新の仕様では変更になっている場合があります。 要約 overscroll-behavior プロパティを使う […]

necomesi.jp

displayの指定には注意

dialog要素にdisplay:flexやdisplay:gridを指定したい場合、そのまま指定してしまうとUAスタイルシートのdisplay:noneが上書きされて表示されてしまいます。

[open]属性セレクタを参照して、開いている時にのみdisplayの値を変更するようにしてください。

🙆‍♂ Recommended
dialog[open] {
	display: block grid;
}

::backdrop疑似要素の取り扱いには注意

::backdrop擬似要素は、dialog要素が最上位レイヤーで表示される直下に出現するレイヤー要素です。

::backdrop - CSS: カスケーディングスタイルシート | MDN

CSS の ::backdrop 擬似要素は、何らかの要素が全画面モード (top layer) で表示される直下に直接表示される viewport の寸法のボックスです。

developer.mozilla.org

dialog要素を使用すれば空のdiv要素を使わずとも標準でオーバーレイ要素が付属してきます。::backdrop擬似要素に適用するプロパティの制限は特に無いので、従来のオーバーレイの実装と同じようにスタイル指定ができます。

CSSの実装例
::backdrop {
	background-color: rgb(0 0 0 / 80%);
	-webkit-backdrop-filter: blur(4px); /* Safariではまだベンダープレフィックスが必要 */
	backdrop-filter: blur(4px);
}

ただし、::backdrop擬似要素には難点があり、Safariは17.3まで::backdrop擬似要素でカスタムプロパティが利用できず(最新版の17.4にて対応(新しいタブで開く))、Firefoxでは::backdrop擬似要素のアニメーションが作動しません。

そのため、しばらくの間は::backdrop擬似要素でカスタムプロパティを使用するのは控えて、::backdrop擬似要素にアニメーションを加えたい場合はFirefoxを無視するか別のアプローチで実装する必要があります。

例えば実装例のサンプル(新しいタブで開く)のようにモーダルウィンドウと一緒に単色のオーバーレイをフェードしながら表示させるだけなら::backdropのopacityを操作しなくてもdialog要素にbox-shadow: 0 0 0 100vmax var(--_shadow-color)を指定し、dialog要素のopacityを切り替えることで似たような表現ができます。

dialog {
	--_duration: 0.5s;
	--_shadow-color: rgb(0 0 0 / 80%);

	box-shadow: 0 0 0 100vmax var(--_shadow-color);
	transition: opacity var(--_duration);
}

dialog:not([data-active='true']) {
	opacity: 0;
}

また、::backdrop擬似要素にclickイベントを付与することはできないため、オーバーレイをクリックするとモーダルウィンドウが閉じる動作を実装する場合には他のアプローチが必要になります。

背面のスクロールを抑制する

前提として、iOSで完全にスクロールを無効にできないことを許容すれば現在ではCSSのみで背面のスクロールを抑制することができます。

body:has(dialog[open]) {
	overflow: hidden;
}

また、この指定だけだと背面コンテンツのスクロールバーの有無で開閉時に背面レイアウトのガタツキが生じてしまいますが、現時点でSafariでサポートされていないことを許容すれば次のCSSを追加することでガタツキの対策もできます。

body {
	overflow-y: scroll; /* 常にスクロールバーを表示 */
}

/* header, main, footerを囲むラッパー要素 */
.site-wrapper:has(dialog[open]) {
	overflow-y: auto;
	scrollbar-gutter: stable; /* スクロールバーのスペースを常に確保する */
}

iOSではbody要素にoverflow:hiddenを指定するだけではアドレスバーとスクロールインジゲーターの有無の組み合わせによっては背面スクロールが有効になってしまいます。再現性が高く、個人的には無視できないと感じました。

そのため、iOSでも完全にスクロールを無効化したい場合は過去に私がZennに投稿した記事で紹介している手法を利用して背面のスクロールを抑制してください。(本記事の投稿に合わせて内容を一部アップデートしています)

Interop 2024の項目にScrollbar Stylingが追加されたことで、将来的にはSafariでもスクロールバーに関するCSSはW3C標準のものに統一される可能性が高いです。そのため、近い未来にSafariでもscrollbar-gutterプロパティがサポートされるかもしれません。

スクロールバーのCSSに関しては過去に投稿した記事「スクロールバーにまつわるエトセトラ」にてまとめております。

モーダルウィンドウにアニメーションを設定する

実装例のopenModal関数とcloseModalでは開閉時のアニメーションを設定するために以下のような施策を行っています。

let isAnimating: boolean = false

const openModal = (modal: HTMLDialogElement): void => {
  if (isAnimating) return

  isAnimating = true
  modal.showModal()
  backfaceFixed(true)

  requestAnimationFrame(async () => {
    modal.setAttribute('data-active', 'true')
    await waitModalAnimation(modal)
    isAnimating = false
  })
}

const closeModal = async (modal: HTMLDialogElement): Promise<void> => {
  if (isAnimating) return

  isAnimating = true
  modal.setAttribute('data-active', 'false')
  backfaceFixed(false)

  await waitModalAnimation(modal)
  modal.close()

  if (currentOpenTrigger) {
    currentOpenTrigger.focus()
    currentOpenTrigger = null
  }

  isAnimating = false
}

CSSアニメーションの管理はdata-active属性で行うようにし、開く際はrequestAnimationFrameを使用して次のレンダリングサイクルまでスタイルの適用を遅らせてmodal.showModal()が行われた後にdata-active="true"を設定します。これにより開く際のtransitionアニメーションが有効化されます。

const openModal = (modal: HTMLDialogElement): void => {
  // ...

  requestAnimationFrame(async () => {
    modal.setAttribute('data-active', 'true') // スタイルが描画されるのを待った後に`data-active=true`を付与
    // ...
  })
}
dialog {
	--_duration: 0.5s;

	transition:
		opacity var(--_duration),
		scale var(--_duration);
}

dialog:not([data-active='true']) {
	opacity: 0;
	scale: 0.95;
}

また、getAnimations()メソッドを使用してアニメーションの終了を待ってから開いた際は連打防止フラグをfalseに、閉じた際はmodal.close()と連打防止フラグのfalseをするようにします。

const waitModalAnimation = (modal: HTMLDialogElement): Promise<PromiseSettledResult<Animation>[]> => {
  if (modal.getAnimations().length === 0) {
    return Promise.resolve([])
  }
  return Promise.allSettled([...modal.getAnimations()].map((animation) => animation.finished))
}

const openModal = (modal: HTMLDialogElement): void => {
  // ...

  requestAnimationFrame(async () => {
    modal.setAttribute('data-active', 'true') // スタイルが描画されるのを待った後に`data-active=true`を付与
    await waitModalAnimation(modal) // アニメーションの終了を待つ
    isAnimating = false
  })
}

const closeModal = async (modal: HTMLDialogElement): Promise<void> => {
  // ...

  await waitModalAnimation(modal) // アニメーションの終了を待つ
  modal.close() // dialogを閉じる
  isAnimating = false
}

transitionendやanimationendとは違い、getAnimations()メソッドを使用することでtransitionおよびanimationどちらを使用しても終了を待つことができ、モーダルウィンドウにアニメーションが設定されていない場合でもawait後のイベントを発火させることが可能となります。

Escキー押下時のkeydownイベントを追加する

デフォルトのEscキーで閉じる機能は使用せず、keydownイベントを監視してEsc押下時はデフォルトの動作を無効化してcloseModal関数を呼び出すようにします。

const handleKeyDown = (event: KeyboardEvent, modal: HTMLDialogElement): void => {
  if (event.key === 'Escape') {
    event.preventDefault()
    closeModal(modal)
  }
}

handleKeyDown関数はモーダルウィンドウが開いた際にaddEventListenerし、モーダルウィンドウが閉じる際にremoveEventListenerします。

// イベントリスナーを保持する
const unsubscribeListeners: Array<() => void> = []

const openModal = (modal: HTMLDialogElement): void => {
  if (isAnimating) return

  isAnimating = true
  modal.showModal()
  backfaceFixed(true)

  const keyDownHandler = (event: KeyboardEvent) => handleKeyDown(event, modal)
  window.addEventListener('keydown', keyDownHandler, false)
  unsubscribeListeners.push(() => window.removeEventListener('keydown', keyDownHandler))

  requestAnimationFrame(async () => {
    modal.setAttribute('data-active', 'true')
    await waitModalAnimation(modal)
    isAnimating = false
  })
}

const closeModal = async (modal: HTMLDialogElement): Promise<void> => {
  if (isAnimating) return

  isAnimating = true
  modal.setAttribute('data-active', 'false')
  backfaceFixed(false)
  unsubscribeListeners.forEach((unsubscribe) => unsubscribe())

  await waitModalAnimation(modal)
  modal.close()

  if (currentOpenTrigger) {
    currentOpenTrigger.focus()
    currentOpenTrigger = null
  }

  isAnimating = false
}

モーダルウィンドウのオーバーレイをクリックするとモーダルウィンドウが閉じるようにする

::backdrop疑似要素にclickイベントを登録することはできないため、クリックイベントのターゲットがモーダルウィンドウ(dialog要素)自体である場合にcloseModal関数を呼び出してモーダルウィンドウを閉じるようにします。

const handleBackdropClick = (event: MouseEvent, modal: HTMLDialogElement): void => {
  if (event.target === modal) {
    closeModal(modal)
  }
}

event.targetがdialog要素であるかどうかをチェックする都合上、コンテンツ部分がクリックされた場合にはtargetはdialog要素自体では無いようにする必要があります。

そのため、マークアップでdialog要素の子にblock-size:100%のコンテナ要素を追加し、paddingなどの指定はコンテナ側に指定するようにします。

<dialog id="modal1" aria-labelledby="headline1" aria-describedby="desc1" autofocus>
	<div class="container">
		<h2 id="headline1">モーダルのタイトル</h2>
		<p id="desc1">モーダルの説明文</p>
	</div>
	<button type="button" aria-labelledby="close1" data-modal-close>
		<span id="close1" style="display:none">モーダルを閉じる</span>
	</button>
</dialog>
.container {
	block-size: 100%;
	padding: 1lh;
}

handleBackdropClick関数はhandleKeyDown関数同様にモーダルウィンドウの開閉時に登録・解除を切り替えます。

const openModal = (modal: HTMLDialogElement): void => {
  if (isAnimating) return

  isAnimating = true
  modal.showModal()
  backfaceFixed(true)

  const backdropClickHandler = (event: MouseEvent) => handleBackdropClick(event, modal)
  modal.addEventListener('click', backdropClickHandler, false)
  unsubscribeListeners.push(() => modal.removeEventListener('click', backdropClickHandler))

  const keyDownHandler = (event: KeyboardEvent) => handleKeyDown(event, modal)
  window.addEventListener('keydown', keyDownHandler, false)
  unsubscribeListeners.push(() => window.removeEventListener('keydown', keyDownHandler))

  requestAnimationFrame(async () => {
    modal.setAttribute('data-active', 'true')
    await waitModalAnimation(modal)
    isAnimating = false
  })
}

モーダルウィンドウを閉じた時に開くトリガーにフォーカスが移るようにする

handleOpenTriggerClickイベントリスナー内で押下されたトリガーを変数に保持し、モーダルウィンドウが閉じた後にfocus()メソッドを使用してそのトリガー要素にフォーカスを移動させるようにします。

let currentOpenTrigger: HTMLButtonElement | null = null

const handleOpenTriggerClick = (event: Event, modal: HTMLDialogElement, trigger: HTMLButtonElement): void => {
  event.preventDefault()
  currentOpenTrigger = trigger
  openModal(modal)
}

const closeModal = async (modal: HTMLDialogElement): Promise<void> => {
  // ...

  await waitModalAnimation(modal)
  modal.close()

  if (currentOpenTrigger) {
    currentOpenTrigger.focus()
    currentOpenTrigger = null
  }

  // ...
}

Safariでの非キーボード操作時のoutlineの出現を抑制する

当ブログでは次のCSSをグローバルに指定することで、マウスやタップ操作時のoutlineの出現を抑制しています。

:focus:not(:focus-visible) {
	outline: none;
}

ただ、モーダルウィンドウが開いた際のオートフォーカスおよび閉じた際のボタンへのフォーカス時にSafariのみ:focus-visibleのスタイルがあたってしまいます。

デザイン的にoutlineが表示されるのは格好がつかず、だからと言って該当箇所にoutline:noneを指定するのはポリシーに反するため、やや強引感ありますが開くトリガーにイベントリスナーを導入し、mousedownイベントが検知された際は:root要素にdata-mousedown属性を付与し、keydownイベントが検知された際はdata-mousedown属性を外すというやり方でoutlineの出現を抑制することとしました。

const initializeModal = (modal: HTMLDialogElement): void => {
  // ...

  openTriggers.forEach((trigger) => {
    trigger.addEventListener('click', (event) => handleOpenTriggerClick(event, modal, trigger), false)
    trigger.addEventListener('mousedown', handleTriggerFocus, false)
    trigger.addEventListener('keydown', handleTriggerFocus, false)
  })

  // ...
}

const handleTriggerFocus = (event: Event): void => {
  if (event.type === 'mousedown') {
    document.documentElement.setAttribute('data-mousedown', 'true')
  }
  if (event.type === 'keydown') {
    document.documentElement.removeAttribute('data-mousedown')
  }
}
次のCSSを適応する
:where(:root[data-mousedown] dialog *) {
	outline: none;
}

[data-modal-open]:where(:root[data-mousedown] *) {
	outline: none;
}

これだけだとモーダルウィンドウ内でキーボード操作が行われた際にoutlineが消えたままになってしまうため、handleKeydown()イベントリスナー内でもkeydownイベントが検知された際に該当の属性を外す処理を追加します。

const handleKeyDown = (event: KeyboardEvent, modal: HTMLDialogElement): void => {
  document.documentElement.removeAttribute('data-mousedown')
  if (event.key === 'Escape') {
    event.preventDefault()
    closeModal(modal)
  }
}

フォーカストラップは未実装

フォーカストラップとは、モーダルウィンドウ内をキーボードで操作する際に背面コンテンツへのアクセスを防ぐためにTab移動時のフォーカスをモーダルウィンドウの領域からはみ出さないようにする施策のことです。

モーダルウィンドウの実装をする際はかつてはフォーカストラップが必須とされていましたが、今回の実装には不要だと感じたので導入しておりません。

理由としてはshowModal()メソッドを利用したdialog要素を利用している時点で背面コンテンツへのfocusの抑制はできていること、加えて「モーダル」だからといってブラウザのUIへのアクセスまで封じる必要性はないと判断したためです。

Tab操作以外でフォーカスを移動できる支援技術においてはフォーカストラップは意味を成さないものとなりますので、フォーカストラップで背面コンテンツへのアクセスを防止している気になっている旧式の実装法には穴があると言わざるを得ない印象です。

参考リンク