dialog要素を使用したモーダルウィンドウの実装例
広告
dialog
要素を使用したアクセシブルなモーダルウィンドウの実装メモです。このブログのハンバーガーメニューで使われている実装と同じものになります。
dialog
要素は現在全てのモダンブラウザでサポートされているため、iOS Safariをどこまで対応するかに依りますが実務で使用しても差し支えないでしょう。
コードとサンプル
JavaScriptの実装はGitHub Gistに纏めています。コードにはTypeScriptを使用していますので、TypeScriptを利用していないWeb制作現場で使用する場合はChatGPTなどに依頼してJSファイルに変換してください。
dialog要素について
dialog
は対話的コンポーネント(ダイアログボックスやモーダルウィンドウ等)を示すHTMLの要素です。
かつてはMicromodal.jsなどのアクセシブルなJSライブラリを利用してモーダルウィンドウを実装するのが主流でしたが、現在ではdialog
要素を使用すればウェブ標準かつアクセシブルなモーダルウィンドウを低コストで実装できます。
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要素に備わっている機能・備わっていない機能
モーダルウィンドウを実装する際、求められる要件は次のとおりです。
- 開くトリガーとなるボタンを押下するとモーダルウィンドウを開く
- 閉じるトリガーとなるボタンを押下するとモーダルウィンドウが閉じる
- モーダルウィンドウを開いた時にフォーカスがモーダルウィンドウ内に移る
- モーダルウィンドウを開いている間は背面のスクロールを抑制する
- モーダルウィンドウを開いている間は背面のコンテンツにフォーカスを移動させない
- モーダルウィンドウを開いている間は背面のコンテンツの読み上げを抑制する
- モーダルウィンドウを開いている間は背面のコンテンツのテキスト選択を抑制する
- モーダルウィンドウのオーバーレイをクリックするとモーダルウィンドウが閉じる
- Escキーでモーダルウィンドウを閉じることができる
- モーダルウィンドウを閉じた時にモーダルウィンドウが開く前にフォーカスされていた要素(開くトリガー)にフォーカスが移る
- モーダルウィンドウはどのコンテンツよりも最前面に表示される
- モーダルウィンドウの開閉にアニメーションを設定できる
dialog
のモーダルには「最上位レイヤーで表示する」「背面のコンテンツをinert(不活性化)する」「Escキーでモーダルウィンドウが閉じる」機能が標準で備わっています。よって上記項目のうち、次の項目に関してはdialog
を利用することで自然と解決することができます。
- 開くトリガーとなるボタンを押下するとモーダルウィンドウを開く
- 閉じるトリガーとなるボタンを押下するとモーダルウィンドウが閉じる
- モーダルウィンドウを開いた時にフォーカスがモーダルウィンドウ内に移る
- モーダルウィンドウを開いている間は背面のコンテンツにフォーカスを移動させない
- モーダルウィンドウを開いている間は背面のコンテンツの読み上げを抑制する
- モーダルウィンドウを開いている間は背面のコンテンツのテキスト選択を抑制する
- Escキーでモーダルウィンドウを閉じることができる(※注意点あり)
- モーダルウィンドウはどのコンテンツよりも最前面に表示される
ただし、後述するdialog
に標準で備わっていない機能を実装する場合、デフォルトのEscキーで閉じる機能を使用すると独自の実装が機能しなかったり、スクロールが固定されたままになるなどの不具合が発生してしまうので、原則的にはkeydownイベントでデフォルトの動作は無効化しつつEscキーを押下したら独自のcloseModal
関数を発火させることを推奨します。
dialog
に標準で備わっていない次の項目は各々で実装を行う必要があります。
- モーダルウィンドウを開いている間は背面のスクロールを抑制する
- モーダルウィンドウのオーバーレイをクリックするとモーダルウィンドウが閉じる
- モーダルウィンドウを閉じた時にモーダルウィンドウが開く前にフォーカスされていた要素(開くトリガー)にフォーカスが移る
- モーダルウィンドウの開閉にアニメーションを設定できる
dialog要素のマークアップ
マークアップの実装例は次のとおりです。
先述したようにJS無効環境は考慮しないので、モーダルを開くor閉じるトリガーとなる要素はbutton
で実装します。
開くトリガーの実装
開くトリガーとなる要素にはdata-modal-open
属性を付与して、値にはターゲットとなるdialog
要素のid
を紐づけます。
トリガーの値とdialog
要素のid
が一致する場合にモーダルウィンドウを開くようにすることで、ページ内に異なるコンテンツを持つモーダルウィンドウが複数存在する場合の対応も行っています。
aria-labelledby属性でのラベリング
どのモーダルウィンドウが表示されているかを支援技術に伝えるためにdialog
要素にはaria-label
もしくはaria-labelledby
属性を付与します。
今回のケースであればモーダルウィンドウには見出し要素を含んでいるため、見出し要素を参照したaria-labelledby
属性でラベリングを行います。
モーダルウィンドウが見出し以外の説明文となるテキストを含んでいる場合にはaria-describedby
属性を使用して関連付けを行うこともできます。
autofocus属性の指定
dialog
要素内には最初にフォーカスが当たるべき要素にautofocus
属性を付与します。
原則的にshowModal()
した場合はdialog
要素内の最初のfocusableな要素にフォーカスが移動しますが、autofocus
属性を指定することでユーザーの利便性を考えた際にベターであろう位置にフォーカスを移動させることができます。
今回の場合はautofocus
属性はdialog
要素に指定しています。
dialog
要素へのautofocus
属性の指定はあくまで適切なフォーカス対象が存在しない場合の最終手段として捉えてください。
また、将来的にはモーダルdialog
の場合はautofocus
属性が必須になるとのことです。
ただし、tabindex
属性が指定されていないdialog
要素へのオートフォーカスは現時点で全ブラウザで対応されておりません。 先述した通りdialog
要素でのtabindex
属性の使用は禁じられているので、釈然としない気持ちは残りますが一旦はこのまま実装することとします。
閉じるトリガーの実装
閉じるトリガーとなる要素にはdata-modal-close
属性を付与します。また、button
の中身がアイコンのみの場合は支援技術に伝わるようにラベリングを行ってください。
aria-label
だと一部のブラウザで機械翻訳がされず、visually hiddenしたテキストだとページ内検索で引っかかってしまうため今回のケースではdisplay:none
したテキストを参照したaria-labelledby
属性でラベリングを行っています。
ラベリングの方法に問題がないかMarkuplint開発者の平尾さんに確認いただいたところ「あり」だと回答いただいたので、今後はハンバーガーボタンのラベルのように選択してコピーしても嬉しくないようなテキストの場合はこの方法も考慮する予定です。
dialog要素のCSS
dialog
要素はUserAgentでスタイリングが施されていますが、UAデフォルトのスタイルは非常に癖が強いです。
UAスタイルシートをリセットする
基本的にこのスタイルを活かしてデザインを反映するのは厳しい印象なので、スタイリングの邪魔になるプロパティは予めリセットしておきます。
リセットの例としては次のような指定です。
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
も指定することを推奨します。
背面のスクロールの抑制は別で行うものの、スクロールが可能な際にラバーバンド効果(スワイプで更新や履歴を戻る処理をする際のバウンス効果)が有効なままだと暴発して操作がしにくい印象となります。
displayの指定には注意
dialog
要素にdisplay:flex
やdisplay:grid
を指定したい場合、そのまま指定してしまうとUAスタイルシートのdisplay:none
が上書きされて表示されてしまいます。
[open]
属性セレクタを参照して、開いている時にのみdisplay
の値を変更するようにしてください。
::backdrop疑似要素の取り扱いには注意
::backdrop
擬似要素は、dialog
要素が最上位レイヤーで表示される直下に出現するレイヤー要素です。
dialog
要素を使用すれば空のdiv
要素を使わずとも標準でオーバーレイ要素が付属してきます。::backdrop
擬似要素に適用するプロパティの制限は特に無いので、従来のオーバーレイの実装と同じようにスタイル指定ができます。
ただし、::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
を切り替えることで似たような表現ができます。
また、::backdrop
擬似要素にclickイベントを付与することはできないため、オーバーレイをクリックするとモーダルウィンドウが閉じる動作を実装する場合には他のアプローチが必要になります。
背面のスクロールを抑制する
前提として、iOSで完全にスクロールを無効にできないことを許容すれば現在ではCSSのみで背面のスクロールを抑制することができます。
また、この指定だけだと背面コンテンツのスクロールバーの有無で開閉時に背面レイアウトのガタツキが生じてしまいますが、現時点でSafariでサポートされていないことを許容すれば次のCSSを追加することでガタツキの対策もできます。
iOSではbody
要素にoverflow:hidden
を指定するだけではアドレスバーとスクロールインジゲーターの有無の組み合わせによっては背面スクロールが有効になってしまいます。再現性が高く、個人的には無視できないと感じました。
そのため、iOSでも完全にスクロールを無効化したい場合は過去に私がZennに投稿した記事で紹介している手法を利用して背面のスクロールを抑制してください。(本記事の投稿に合わせて内容を一部アップデートしています)
Interop 2024の項目にScrollbar Stylingが追加されたことで、将来的にはSafariでもスクロールバーに関するCSSはW3C標準のものに統一される可能性が高いです。そのため、近い未来にSafariでもscrollbar-gutter
プロパティがサポートされるかもしれません。
スクロールバーのCSSに関しては過去に投稿した記事「スクロールバーにまつわるエトセトラ」にてまとめております。
モーダルウィンドウにアニメーションを設定する
実装例のopenModal
関数とcloseModal
では開閉時のアニメーションを設定するために以下のような施策を行っています。
CSSアニメーションの管理はdata-active
属性で行うようにし、開く際はrequestAnimationFrame
を使用して次のレンダリングサイクルまでスタイルの適用を遅らせてmodal.showModal()
が行われた後にdata-active="true"
を設定します。これにより開く際のtransition
アニメーションが有効化されます。
また、getAnimations()
メソッドを使用してアニメーションの終了を待ってから開いた際は連打防止フラグをfalse
に、閉じた際はmodal.close()
と連打防止フラグのfalse
をするようにします。
transitionend
やanimationend
とは違い、getAnimations()
メソッドを使用することでtransition
およびanimation
どちらを使用しても終了を待つことができ、モーダルウィンドウにアニメーションが設定されていない場合でもawait
後のイベントを発火させることが可能となります。
Escキー押下時のkeydownイベントを追加する
デフォルトのEscキーで閉じる機能は使用せず、keydown
イベントを監視してEsc押下時はデフォルトの動作を無効化してcloseModal
関数を呼び出すようにします。
handleKeyDown
関数はモーダルウィンドウが開いた際にaddEventListener
し、モーダルウィンドウが閉じる際にremoveEventListener
します。
モーダルウィンドウのオーバーレイをクリックするとモーダルウィンドウが閉じるようにする
::backdrop
疑似要素にclick
イベントを登録することはできないため、クリックイベントのターゲットがモーダルウィンドウ(dialog
要素)自体である場合にcloseModal
関数を呼び出してモーダルウィンドウを閉じるようにします。
event.target
がdialog
要素であるかどうかをチェックする都合上、コンテンツ部分がクリックされた場合にはtarget
はdialog
要素自体では無いようにする必要があります。
そのため、マークアップでdialog
要素の子にblock-size:100%
のコンテナ要素を追加し、padding
などの指定はコンテナ側に指定するようにします。
handleBackdropClick
関数はhandleKeyDown
関数同様にモーダルウィンドウの開閉時に登録・解除を切り替えます。
モーダルウィンドウを閉じた時に開くトリガーにフォーカスが移るようにする
handleOpenTriggerClick
イベントリスナー内で押下されたトリガーを変数に保持し、モーダルウィンドウが閉じた後にfocus()
メソッドを使用してそのトリガー要素にフォーカスを移動させるようにします。
Safariでの非キーボード操作時のoutlineの出現を抑制する
当ブログでは次のCSSをグローバルに指定することで、マウスやタップ操作時のoutline
の出現を抑制しています。
ただ、モーダルウィンドウが開いた際のオートフォーカスおよび閉じた際のボタンへのフォーカス時にSafariのみ:focus-visible
のスタイルがあたってしまいます。
デザイン的にoutline
が表示されるのは格好がつかず、だからと言って該当箇所にoutline:none
を指定するのはポリシーに反するため、やや強引感ありますが開くトリガーにイベントリスナーを導入し、mousedown
イベントが検知された際は:root
要素にdata-mousedown
属性を付与し、keydown
イベントが検知された際はdata-mousedown
属性を外すというやり方でoutline
の出現を抑制することとしました。
これだけだとモーダルウィンドウ内でキーボード操作が行われた際にoutline
が消えたままになってしまうため、handleKeydown()
イベントリスナー内でもkeydown
イベントが検知された際に該当の属性を外す処理を追加します。
フォーカストラップは未実装
フォーカストラップとは、モーダルウィンドウ内をキーボードで操作する際に背面コンテンツへのアクセスを防ぐためにTab移動時のフォーカスをモーダルウィンドウの領域からはみ出さないようにする施策のことです。
モーダルウィンドウの実装をする際はかつてはフォーカストラップが必須とされていましたが、今回の実装には不要だと感じたので導入しておりません。
理由としてはshowModal()
メソッドを利用したdialog
要素を利用している時点で背面コンテンツへのfocusの抑制はできていること、加えて「モーダル」だからといってブラウザのUIへのアクセスまで封じる必要性はないと判断したためです。
Tab操作以外でフォーカスを移動できる支援技術においてはフォーカストラップは意味を成さないものとなりますので、フォーカストラップで背面コンテンツへのアクセスを防止している気になっている旧式の実装法には穴があると言わざるを得ない印象です。