アクセシビリティ重視のdialog式ハンバーガーメニュー解説
JavaScriptを知りたいと思い、自作のテーマに組み込める機能はないかと調べると、それまで使っていたCSSだけのハンバーガーメニューは色々な問題があることを知りました。Input要素でボタンを作ってもTabキーでフォーカスが効きません。できるだけ多くの方にとって使いやすいWebを提供できるように「アクセシビリティ」に配慮して作成する必要があることを今まで考慮していませんでした。
学習を兼ねてアクセシビリティを最優先に、ハンバーガメニューを作ることにし、仕様を調べたところ以下のリストの様に作れば良いらしいことがわかりました。特にdialog要素を使うべきで、現在Chromium系ブラウザ(Chorme,Edgeなど)はJavaScriptを使わずにdialog要素のハンバーガーメニューが作成できるようになっているようです。
dialog要素を使ったハンバーガーメニューに必要な機能
- 開閉ボタンはbutton要素で作成する。
- メニューの表示はdialog要素を使う。
- メニュー内リンクにautofocus属性を付与する。
- キーボードで開くとフォーカスはautofocus属性の含まれたリンクに合うようようにする。
- メニューが開いたときのTabキーでの移動をメニュー内で循環させる。
- メニュー内Shift+Tabを押すと逆順にフォーカスを動かす。
- メニュー内で循環させる内にメニューを閉じるボタンを含ませる。
- キーボードでメニューを閉じるとフォーカスはメニューを開くボタンに移動する。
- メニューが閉じている場合はTabキーでメニューに移動させない。
- メニューが開いている場合にメインコンテンツを非活性化する。
- メニューが開いている場合にメニュー以外を操作するとメニューを閉じる。
- メニューが開いている場合はメインコンテツのスクロールを止める。
- メニューをEscapeキーで閉じる。
- aria-label属性を付与する。
- h1要素でdialog要素の内容を表示する。
- dialog要素にaria-labelledby属性を付与しh1のIDに向ける。
- メニューの開閉状態によりaria-expanded=trueとaria-expanded=falseを切り替える。
- メニューの開閉状態によりbuttonにdisable属性を切り替える。
- デスクトップのChomeでメニューの開閉時に起こるレイアウトの動きをなくす。
- iPhoneのSafariでメニューを開いた場合のフォーカスの表示をなくす。
- メニューの開閉にアニメーションを付ける。
dialog要素は最上位のレイヤーに位置する
これまで使っていたCSSだけのメニューと同じ、CSSで書かれた3本線が動いて閉じるマークに変化するアニメーションを使います。ただdialog要素はshowModal()で開くと最上位に表示されz-indexに影響を受けません。position:fixedで固定されたbuttonはdialog要素とdialog:backdropに覆われて操作できません。そこで、同じ形のbuttonをdialog要素内におき、2つのbutton要素を重ねる事で対処します。
フォーカストラップの範囲は議論のあるところ
「フォーカストラップ」といわれる、メニューが開いた時のフォーカスの制御は、JavaScriptでメニューの中のリンクに制限しています。これには異論もあるようで、ブラウザの機能(タブやメニューバー)にアクセスするほうが良いという意見も多く見ました。下のコードのJavaScriptに含まれるフォーカストラップの部分を削除すればdialog要素の標準機能である前記のメニューとブラウザの機能にアクセスできるようになります。
CSSでレイアウトの動きを抑制
いつもFirefoxを使っているので今まで知らなかったのですが、Chromeはメニューバーに幅があり全画面と解除を切り替えるとレイアウトが横に動きます。これはCSSで修正できます。常にスクロールバーの幅を保持する事で全画面になってもレイアウトの動きが無くなります。
html {
scrollbar-gutter: stable;
}
CSSでメイン画面のスクロールを止める
メニューが開いたときにメイン画面を不活性にするためにスクロールを止めます。JavaScriptで止めるかCSSで止めるか、JavaScriptの場合はスクロールバーをマウスでドラッグすると、メイン画面は動きますがそれ以外の操作では動きません。CSSでoverflow:hiddenだけを使う方法はiPhoneのSafariで効果がありません。そこで次のプロパティを使います。
html:has(dialog#d1[open]) {
overflow: hidden;
overscroll-behavior: none
}
display:noneをアニメーションさせるには
通常はtransitionプロパティによってCSSアニメーションを動かしますが、display:noneからdisplay:blockに変化する場合はこの方法は使えません。そこで@starting-styleを使いdisplay:noneの時のCSSを定義します。下はdialog要素のアニメーションに関する部分だけを抜き出したものです。変化の前後に加えて@starting-styleによる定義が必要です。この時、display:noneで消えているにも関わらずopacity:0が必要なのは、opacityプロパティでアニメーションする場合は全てにopacityの設定が必要だからです。ない場合は表示されるアニメーションだけになり、消える際はアニメーションがなく0.8秒後に一斉に消えます。
dialog#d1 {
opacity: 0;
transition: all .8s allow-discrete;
}
dialog#d1[open] {
opacity: 1
}
@starting-style {
dialog#d1[open]{
opacity: 0
}
}
Safariのフォーカスを消すには
すべてが思うように動作しiPhoneのSafariでメニューを開くと、autofocus属性を付与したリンクにフォーカスが表示されます。生成AIにたずねても解決できなくて困っていたところ、解決方法を的確に解説されているのを見つけられたのは幸運でした。
方法はマウスなどのキーボード以外の操作を検知した場合にhtml要素に属性を付与し、CSSでoutline: noneによってフォーカスを消すというもので、わかりやすい解説のおかげで私でも実装できました。
Safariの予期しない動作
daialog要素を動かすとiPhoneのSafariで動作がおかしいです。まず、dialog要素を表示させた後に消えるアニメーションの最中は最上位の特性が消えます。おそらくz-index:0の状態です。消えるときに画像の下に潜り込んだり他のz-indexを指定された要素の下に表示されるので、dialog要素にz-indexを使っています。
dialog要素には初期値でposition:fixedがかかっていますが、省略するとiPhoneのSafariで動作に不具合が出るようです。あわせてinset:0を追加すると良いです。
コピペで使えるHTML・JavaScript・CSS
以下のHTML・JavaScript・CSSを使えばモバイルで使えるようにしました。
<button id="d2" class="d1 d5" type="button" aria-expanded="false" aria-controls="d1" aria-label="メニューを開く"><span class="d2"></span></button>
<dialog id="d1" aria-labelledby="d3">
<nav>
<h1 id="d3">メニュー</h1>
<a class="d3" autofocus href="">リンク1</a>
<a class="d3" href="">リンク2</a>
<a class="d3" href="">リンク3</a>
</nav>
<button class="d4 d5" type="button" aria-expanded="true" aria-controls="d1" aria-label="メニューを閉じる"><span class="d2"></span></button>
</dialog>
// ---------------------------
// グローバルイベントリスナーでマウス操作とキーボード操作を検知
// ---------------------------
document.documentElement.addEventListener('mousedown', () => {
document.documentElement.setAttribute('dm', '');
}, { passive: true });
document.documentElement.addEventListener('keydown', (event) => {
if (
event.key === "Tab" ||
event.key === "Enter" ||
event.key === "Escape" ||
event.key.startsWith("Arrow")
) {
document.documentElement.removeAttribute('dm');
}
}, { passive: true });
// ---------------------------
// DOM取得
// ---------------------------
const dialog = document.getElementById('d1');
const openTriggerButton = document.getElementById('d2');
const closeTriggerButton = dialog ? dialog.querySelector('.d4') : null;
// ---------------------------
// ダイアログが閉じる際の共通処理
// ---------------------------
function handleDialogClose() {
openTriggerButton.classList.remove('io');
closeTriggerButton.classList.remove('io');
openTriggerButton.disabled = false;
openTriggerButton.setAttribute('aria-expanded', 'false');
openTriggerButton.removeAttribute('dn');
openTriggerButton.focus({ preventScroll: true });
closeTriggerButton.disabled = true;
closeTriggerButton.setAttribute('aria-expanded', 'false');
// closeTriggerButton.style.display = 'none'; // 明示的に非表示にする場合
}
// ---------------------------
// メニューを開く処理
// ---------------------------
function openMenu() {
openTriggerButton.classList.add('io');
closeTriggerButton.classList.add('io');
openTriggerButton.disabled = true;
openTriggerButton.setAttribute('aria-expanded', 'true');
openTriggerButton.setAttribute('dn', '');
closeTriggerButton.style.display = '';
closeTriggerButton.disabled = false;
closeTriggerButton.setAttribute('aria-expanded', 'true');
dialog.showModal();
// モーダル内のキー操作で dm 属性を除去
const handleDialogKeyDown = (event) => {
if (event.key === "Tab" || event.key.startsWith("Arrow")) {
document.documentElement.removeAttribute('dm');
}
};
dialog.addEventListener('keydown', handleDialogKeyDown, { passive: true });
dialog.addEventListener('close', () => {
dialog.removeEventListener('keydown', handleDialogKeyDown);
}, { once: true });
}
// ---------------------------
// イベントリスナーの設定
// ---------------------------
// 開く専用ボタン(id=d2)
openTriggerButton.addEventListener('click', openMenu);
// 閉じる専用ボタン(class=d4)
closeTriggerButton.addEventListener('click', () => {
if (dialog.hasAttribute('open')) {
dialog.close();
}
});
// ダイアログが閉じられたときの共通処理
dialog.addEventListener('close', handleDialogClose);
// Backdropクリックでダイアログを閉じる処理
dialog.addEventListener("click", (event) => {
const rect = dialog.getBoundingClientRect();
const inDialog =
rect.top <= event.clientY &&
event.clientY <= rect.top + rect.height &&
rect.left <= event.clientX &&
event.clientX <= rect.left + rect.width;
if (!inDialog) {
dialog.close();
}
});
// ---------------------------
// フォーカストラップの実装(Tabキーでモーダル内を循環)
// ---------------------------
const focusableElementsSelector = 'a,button';
dialog.addEventListener("keydown", function (event) {
if (event.key === "Tab") {
event.preventDefault();
const focusableElements = Array.from(
dialog.querySelectorAll(focusableElementsSelector)
);
const focusedItemIndex = focusableElements.indexOf(document.activeElement);
if (event.shiftKey) {
// Shift + Tab
if (focusedItemIndex === 0) {
focusableElements[focusableElements.length - 1].focus();
} else {
focusableElements[focusedItemIndex - 1].focus();
}
} else {
// Tab
if (focusedItemIndex === focusableElements.length - 1) {
focusableElements[0].focus();
} else {
focusableElements[focusedItemIndex + 1].focus();
}
}
}
});
// ---------------------------
// 初期状態の設定
// ---------------------------
closeTriggerButton.disabled = true;
closeTriggerButton.setAttribute('aria-expanded', 'false');
openTriggerButton.disabled = false;
openTriggerButton.setAttribute('aria-expanded', 'false');
html {
scrollbar-gutter: stable
}
.d5 {
position: fixed;
top: 15px;
right: 15px;
display: flex;
height: 50px;
width: 50px;
justify-content: center;
align-items: center;
z-index: 4;
border: none;
border-radius: 50%;
transition: .8s;
background: #eee;
cursor: pointer
}
.d2,
.d2:before,
.d2:after {
content: '';
display: block;
height: 2px;
width: 25px;
border-radius: 2px;
background: #444;
transition: .3s;
position: absolute
}
.d2:before {
bottom: 8px
}
.d2:after {
top: 8px
}
dialog#d1 {
position: fixed;
inset: 0;
max-width: unset;
color: unset;
border: unset;
overflow: unset;
margin: unset;
inset-inline-start: unset;
inset-inline-end: unset;
inset-block-start: unset;
inset-block-end: unset;
border: none;
width: 300px;
max-height: 100svh;
height: 100vh;
right: 0;
top: 0;
padding: 6em 3em 0 3em;
background: #111;
opacity: 0;
transition: all .8s allow-discrete;
z-index: 9
}
.d5.io {
background: #333
}
.d5.io .d2 {
background: transparent
}
.d5.io .d2:before {
bottom: 0;
transform: rotate(45deg);
background: #ccc
}
.d5.io .d2:after {
top: 0;
transform: rotate(-45deg);
background: #ccc
}
#d3 {
font-size: 1em;
margin: 0;
font-weight: 400;
color: #ccc
}
a.d3:visited {
color: #ccc
}
a.d3 {
display: block;
color: #ccc
}
dialog::backdrop {
background: #666666bb;
backdrop-filter: blur(3px);
opacity: 0;
transition: all .3s allow-discrete
}
dialog#d1[open],
dialog#d1[open]::backdrop {
opacity: 1
}
@starting-style {
.d5.io .d2:before,
.d5.io .d2:after {
transform: rotate(0deg);
background: #444
}
dialog#d1[open],
dialog#d1[open]::backdrop {
opacity: 0
}
}
html:has(dialog#d1[open]) {
overflow: hidden;
overscroll-behavior: none;
}
:where(html[dm] dialog *) {
outline: none
}
[data-modal-open]:where(html[dm] *) {
outline: none
}
コード自体や解決方法を教えていただきました。文末になりますが以下の作者に大きな感謝を申し上げます。
- dialog要素を使用したモーダルウィンドウの実装例 – TAKLOG
- フォーカストラップとは? 〜アクセシブルなモーダル〜
- HTMLのdialog要素の背景をクリックしてdialog要素を閉じる方法
- <dialog>内でautofocus属性がほぼ必須になる話
- JavaScriptを書かない2025年のモーダルの実装方法 – TAKLOG
最後までお読みいただき、誠にありがとうございます。