🍔

アクセシブルなハンバーガーメニュー

に公開

はじめに

こんにちは、もりみちです。
本記事では、<dialog>を使ってアクセシブルなハンバーガーメニューを実装する方法を紹介します。従来の<div>による実装では、いくつかの機能を自前で実装する必要がありましたが、<dialog>を使用することで、よりシンプルかつ堅牢なハンバーガーメニューの実装が可能です。

デモ

まず完成形をお見せします。

なぜ<dialog>を使用するのか

<dialog>が持つネイティブな機能とメリットは以下の通りです。

  • showModal() / show() / close() といったシンプルなAPIで開閉を制御できる
  • Escキーでダイアログを閉じる機能が標準で備わっている
  • フォーカスをダイアログ内に留める機能(モーダル表示時)
  • ページの他の部分を不活性にする::backdrop疑似要素

もしdialog要素を使用せずにdiv要素で実装する場合は、自前で上記を実装する必要があります。
dialogを使わずにモーダルを実装する方法については、以下の記事が参考になります。
https://ics.media/entry/230406/#dialogタグを使用しないモーダルなuiで、背面を制御する

解説

それでは実装を「HTML」「CSS」「JavaScript」の3ステップに分けて、順を追って解説します。

HTML

トリガーとなるボタン

index.html
<!-- 開くボタン -->
<button type="button" class="button open-button" aria-label="メニューを開く" aria-expanded="false" aria-controls="menu">
  <span class="line-box">
    <span class="line"></span>
    <span class="line"></span>
  </span>
</button>  

<!-- 閉じるボタン -->
<button type="button" class="button close-button" aria-label="メニューを閉じる" aria-expanded="true" aria-controls="menu">
  <span class="line-box">
    <span class="line"></span>
    <span class="line"></span>
  </span>
</button>

ボタンはアイコンのみで構成されているため、スクリーンリーダーのユーザーにその役割を伝えるためにaria-labelを使用して、それぞれのボタンに対して適切なラベル(「メニューを開く」「メニューを閉じる」)を設定しています。また、aria-expandedで開閉状態を、aria-controlsで操作対象のdialogのidを指定しています。

ハンバーガーメニュー

index.html
<dialog class="dialog" id="menu" aria-label="メニュー">
  <div class="dialog__inner">
    <nav class="nav" aria-label="グローバルナビゲーション">
      <ul class="list">
        <li class="item"><a class="link" href="/about">about</a></li>
        <li class="item"><a class="link" href="/works">works</a></li>
        <li class="item"><a class="link" href="/news">news</a></li>
        <li class="item"><a class="link" href="/contact">contact</a></li>
      </ul>
    </nav>
  </div>
</dialog>

各種ボタンのaria-controlsと関連付けるためのidを設定しています。aria-labelにはdialog自体の役割(「メニュー」など)を定義してます。また、dialog内部にはナビゲーションの役割を持つ<nav>要素を配置し、これにもaria-label="グローバルナビゲーション"を設定しています。これにより、複数のナビゲーションが存在する場合でも、それぞれの役割を明確に伝えることができます。

例えば、サイトナビゲーションを一つ、ページ内ナビゲーションを一つなどです。このような場合、アクセシビリティを強化するために、aria-labelledbyを使用することができます。(MDNより引用)

CSS

style.css
.dialog {
  transition: opacity 0.2s ease-out;
}
.dialog:not(.-open) {
  opacity: 0;
}

<dialog>に.-openクラスを付与してopacityの調整を行っている理由は、showModal()メソッドやclose()メソッドを使用すると、displayプロパティが直接切り替わるため、opacityなどのCSSアニメーションが適用されません。そのため、クラスを付与・削除することでopacityを制御し、フェードイン・アウトのアニメーションを実現しています。

JavaScript

メニューを開く

script.ts
const openDialog = () => {
  dialog?.showModal();
  dialog?.classList.add("-open");
  dialogLogo?.focus();
};

<dialog>を表示させるshowModal()メソッドを実行後、見た目の調整のため-openクラスを付与してopacityの調整を行っています。また、dialogLogo?.focus()を実行することで、開いたメニュー内の任意の要素(ここではdialogLogo)にフォーカスを移動させ、キーボードユーザーがすぐにダイアログ内の操作を開始できるように配慮しています。

メニューを閉じる

script.ts
const closeDialog = () => {
  dialog?.addEventListener(
    "transitionend",
    () => {
      if (dialog) {
        dialog.close();
        openButton?.focus();
      }
    },
    { once: true }
  );
  dialog?.classList.remove("-open");
};

close()メソッドは実行後すぐに<dialog>を閉じるため、まず開いた時に付与した-openクラスを削除し、閉じるアニメーションを実行させます。transitionendイベントを使ってアニメーションが終了したタイミングで<dialog>を閉じ、メニューを開いた元のボタン(openButton)にフォーカスを戻して操作の連続性を確保しています。

メニュー外クリックで閉じる

script.ts
const handleOutsideClick = (e: MouseEvent) => {
  if (!dialog?.open) return;
  if (e.target instanceof Element) {
    if (!dialogInner?.contains(e.target)) {
      closeDialog();
    }
  }
};

ハンバーガーメニューの背景部分をクリックした際にメニューを閉じるための処理です。メニューをフルスクリーンで表示している場合は不要なこともありますが、背景(オーバーレイ)を設けている場合は、メニューの外側をクリックした時にメニューを閉じる必要があると思います。そのため、メニューの外側をクリックした時にメニューを閉じる処理を行っています。

Escキーでメニューを閉じる

script.ts
const handleEscapeKey = (event: KeyboardEvent) => {
  if (event.key === "Escape" && dialog?.open) {
    event.preventDefault();
    closeDialog();
  }
};

dialogはデフォルトでEscキーを押した時に閉じることができますが、それだとアニメーションが適用されません。そのため、event.preventDefault()を実行してデフォルトの動作をキャンセルし、代わりに自作のcloseDialog()関数を呼び出すことで、アニメーションを閉じる処理を実現しています。

配慮したアクセシビリティのポイント

ハンバーガーメニューを実装する上で、単に「動く」だけでなく、いくつかの重要なアクセシビリティへの配慮を組み込みました。ここでは、配慮したセマンティックなHTML、WAI-ARIA、フォーカス管理、キーボード操作の保証について、なぜそれが必要なのかを具体的に解説します。

セマンティックなHTML

<button>を使う理由

メニューを開閉するトリガーは、<div><span>でも見た目上は実装可能です。
しかし、<button>を使うべき明確な理由があります。

キーボード操作が標準で可能に
<button>は、デフォルトでEnterキーとSpaceキーで押すことができます。もし<div>で代用すると、この動作をJavaScriptで別途実装する必要があり、手間がかかる上に実装漏れのリスクもあります。

スクリーンリーダーへの役割伝達
スクリーンリーダーは<button>要素を「ボタン」として認識し、ユーザーに「これは押せる要素です」と伝えます。しかし<div>では、それが何なのか、操作可能なのかが伝わりません。
また、type="button"と指定することで、フォーム送信(type="submit")を意図しない、純粋なインタラクションのためのボタンであると明示できます。

WAI-ARIA

aria-expanded
ボタンが開閉機能を持つことを示し、現在の状態が「開いている(true)」か「閉じている(false)」かをスクリーンリーダーに伝えます。これにより、ユーザーは操作の結果を正確に把握できます。

aria-label
要素にアクセシブルな名前を与えます。今回の実装のように、テキストを持たないアイコンボタンでは必須の属性です。aria-labelがない場合、スクリーンリーダーはボタンの目的を伝えることができず、ユーザーは何のためのボタンか分かりません。今回の実装では、ボタンの状態に応じて「メニューを開く」「メニューを閉じる」といった具体的なラベルを付与することで、視覚情報に頼らずとも役割が明確に伝わるようにしています。

フォーカス管理の重要性

フォーカストラップの概念
モーダルダイアログが表示されている間、キーボードのTabキーによるフォーカス移動が、ダイアログの背後にあるページ本体に及ばないように、フォーカスをダイアログ内に閉じ込める必要があります。
<dialog>要素をshowModal()で開くと、このフォーカストラップがネイティブ機能として提供されるため、自前で複雑な実装をする必要がありません。これが<dialog>を使う大きな利点です。

まとめ

今回は<dialog>を使ったハンバーガーメニューの実装方法について解説をしました。
アクセシビリティへの配慮は、特定のユーザーのためだけでなく、すべてのユーザーにとっての使いやすさを向上させる重要な要素です。
この記事が、皆さんの実装の参考になれば幸いです。

参考

https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
https://developer.mozilla.org/ja/docs/Web/HTML/Reference/Elements/dialog
https://developer.mozilla.org/ja/docs/Web/HTML/Reference/Elements/button
https://developer.mozilla.org/ja/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-expanded
https://developer.mozilla.org/ja/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-label

Discussion