アクセシビリティを考慮したハンバーガーメニューの作り方

アクセシビリティを考慮したハンバーガーメニューのモダンな作り方

単純に見た目だけでハンバーガーメニューを作ることは簡単ですが、実際にコンピューターや視覚障害者に開閉状態を伝えるためにはアクセシビリティを考慮したコーディングが必要になります。

実務でほぼそのままで使えるようにした上で、ボタンとメニューの作成に焦点を絞って解説します。

目次

ハンバーガーメニューの実装例

  • WAI-ARIAで開閉状態をスクリーンリーダーが認識できる
  • オーバーレイ(背景)をクリックしてメニューを閉じる
  • メニューを開いた状態でページをスクロールさせない
  • キーボード操作が可能
    • 閉じたメニューにフォーカスが当たらない
    • Escキーで閉じる
    • フォーカス移動をメニュー内でループ

See the Pen ハンバーガーメニュー(レスポンシブ対応) by hisa (@hisaaashi) on CodePen.

PC表示では横並びメニュー、モバイル表示ではハンバーガーメニューとよくある構成にしました。

ボタンの実装

<button 
  type="button" 
  id="menu-button" 
  class="menu-button" 
  aria-expanded="false" 
  aria-controls="menu" 
  aria-label="メニューボタン"
>
  <span class="menu-button__bar"></span>
</button>

button要素にはアクセシビリティを高めるため、以下のWAI-ARIAの属性を付与しています。

  • aria-expanded:要素が展開されているかどうかを示す属性
  • aria-controls:この要素が制御する対象のIDを指定し、関連付けを示す属性
  • aria-label:視覚的な文字がない要素に名前(ラベル)を指定し、スクリーンリーダーに伝える属性

WAI-ARIAは、本来は不足している情報を補完するために使用されるべきものであり、最低限の使用が推奨されています。

type属性は、省略しても問題ありませんが、フォーム送信を行わないボタンであることを明示するためにtype="button"を指定しています。

CSSのポイント

アイコンはspan要素で作成し、「MENU」ラベルは擬似要素で表示と言った具合です。

ラベルの有無(アイコンのみの状態)に関わらず上下中央寄せするには、place-itemsplace-contentの併用が便利です。

/* ボタン */
.menu-button {
  display: grid;
  place-items: center;
  place-content: center;
  ...
}

/* ボタンアイコン */
.menu-button__bar {
  ...
}

/* ボタンラベル */
.menu-button::after {
  content: "MENU";
}

[data-drawer-open="true"] .menu-button::after {
  content: "CLOSE";
}

メニューの実装

<div id="menu" class="header__menu">
  <nav class="global-nav">
    <ul class="global-nav__list">
      <li><a href="#sec1">セクション1</a></li>
      <li><a href="#sec2">セクション2</a></li>
      <li><a href="#sec3">セクション3</a></li>
      <li><a href="#sec4">セクション4</a></li>
      <li><a href="#sec5">セクション5</a></li>
    </ul>
</div>

メニューが多い場合には、スクロールして全て表示できるようにすることが重要です。

この例では、親のdiv要素の中で、nav要素(メニューの本体)をスクロールさせる形になっています。

.header__menu {
  position: fixed;
  width: min(300px, 100%);
  height: 100%;
  padding-top: var(--header-h);
  ...
}

.global-nav {
  overflow-y: auto;
  height: 100%;
  ...
}

オーバーレイの実装

<header class="header">
  <div class="header__inner">
    ...
  </div>

  <div class="overlay"></div>
</header>

オーバーレイはヘッダー内部のコンテンツとは役割が異なり、画面全体を覆うレイヤーとして扱うため、ヘッダーのコンテンツを囲む要素の外に配置しています。

.overlay {
  visibility: hidden;
  opacity: 0;
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  transition: visibility 0.3s, opacity 0.3s;

  /* オープン時 */
  [data-drawer-open="true"] & {
    visibility: visible;
    opacity: 1;
  }
}

@media (min-width: 960px) {
  .overlay {
    display: none;
  }
}

稀なケースですが、メニューを開いたままPC幅までウインドウを広げると、オーバーレイが表示されたままになってしまいます。

PC幅ではオーバーレイは不要なので、あらかじめ非表示にしておくことで、こうしたケースにも対応できます。

背景のスクロールを禁止する

メニューを開いている時、背景(ページ)のスクロールを禁止するには、以下の指定をします。

html {
  /* スクロールバーの有無によるガタつき防止 */
  scrollbar-gutter: stable;
}

body {

  /* オープン時 */
  [data-drawer-open="true"] & {
    overflow: clip;
  }
}

スクロールを禁止する際は、scrollbar-gutter: stableでスクロールバーの領域をあらかじめ確保しておくことで、メニューを開閉時のコンテンツのガタつきを防ぐことができます。

ちなみにoverflow: hiddenは、子要素でstickyが正常に機能しなくなる場合があるため、基本的にNG。

JavaScriptで開閉の実装

開閉だけの実装であればシンプルです。

const html = document.documentElement;
const menuButton = document.getElementById("menu-button");
const menu = document.getElementById("menu");
const overlay = document.getElementById("overlay");

// 初期状態をセット
html.dataset.drawerOpen = "false";

// オープン時
function openMenu() {
  html.dataset.drawerOpen = "true";
  menuButton.setAttribute("aria-expanded", "true");
  menu.inert = false;
}

// クローズド時
function closeMenu() {
  html.dataset.drawerOpen = "false";
  menuButton.setAttribute("aria-expanded", "false");
  menu.inert = true; 
}

function toggleMenu() {
  html.dataset.drawerOpen === "true" ? closeMenu() : openMenu();
}

// クリックで開閉
menuButton?.addEventListener("click", toggleMenu);
overlay?.addEventListener("click", closeMenu);

開閉状態はカスタムデータ属性で管理

dataset.drawerOpenは、カスタムデータ属性data-drawer-openを付与して、ドロワーの開閉状態を管理するためのものです。

  • data-drawer-open="false":閉じた状態
  • data-drawer-open="true":開いた状態

is-openなどのクラスで開閉を切り替える方法もありますが、サイト全体の状態はHTML要素にカスタムデータ属性で管理する方が、見通しが良くなり、実装や保守もしやすくなります。

閉じているときはフォーカス・操作を無効化

Tabキーで移動している際に、閉じているメニュー内にフォーカスが当たってしまうと、フォーカスを見失ってしまい、アクセシビリティ上好ましくありません。

そこで、inert属性を指定して子孫要素を含めてフォーカスや操作を無効化し、メニューの開閉に合わせてmenu.inertを切り替えています。

将来的に、interactivity: inertが主要ブラウザで利用できるようになれば、inertの切り替えをCSSだけでできるようになる予定です。

オプション追加

デモコードではすべて含めていますが、それぞれ独立したコードになっているため、必要ない場合は該当するコードを削除して使用してください。

ページ内リンクをクリックしたら閉じる

// ページ内リンクをクリックしたら閉じる
menu?.querySelectorAll("a[href*='#']").forEach((link) => {
  link.addEventListener("click", () => {
    if (!menuButton?.checkVisibility()) return;
    closeMenu();
  });
});

ページ内リンクをクリックした際に、メニューが開きっぱなしだと、リンク先に移動したことが分かりにくくなるため、基本的に閉じる処理を加えておいた方が設計として自然です。

Escキーで閉じる

// Escキーで閉じる
window.addEventListener("keydown", (e) => {
  if (e.key === "Escape" && html.dataset.drawerOpen === "true") {
    closeMenu();
    menuButton.focus();
  }
});

Escキーを使ってメニューを閉じられるようになり、閉じた後はフォーカスをボタンに戻すことで、再びメニューを開くなどの操作をスムーズに行えます。

inertのレスポンシブ対応

// inertのレスポンシブ対応
if (menuButton && menu) {
  const menuObserver = new ResizeObserver(() => {
    const isDrawer = menuButton.checkVisibility();
    const isOpen = html.dataset.drawerOpen === "true";

    if (isDrawer) {
      // ドロワー時は開閉状態に応じてinertを制御
      menu.inert = !isOpen;
    } else {
      // 非ドロワー時はメニューを閉じて操作可能にする
      if (isOpen) closeMenu();
      menu.inert = false;
    }
  });

  menuObserver.observe(menuButton);
}

何もしないままだと、画面幅を可変させた際に以下のような問題が起こります。

  • PC幅→スマホ幅:inertが解除されたままになり、閉じたメニューにもTabキーで移動できてしまう
  • スマホ幅→PC幅:inertを付けたままにすると、メニューを操作できなくなってしまう

折りたたみスマホや、縦から横へ画面の向きを変えた時などを考慮すると、レスポンシブでの切り替えも必要になってきます。

まずはスマホ幅であるかどうかを確認するために、ResizeObserver()を使ってボタンのサイズ変化を監視し、checkVisibility()でボタンが表示されているかを確認します。

ボタンが表示されている場合はスマホ幅と判断し、メニューの開閉状態に応じてinertを切り替え、PC幅では、メニューを開いていれば閉じる処理をして、inertを解除しています。

閉じる処理は任意ですが、他の処理との組み合わせによっては状態に矛盾が生じる可能性があるため、閉じて状態をリセットしておく方が安全です。

フォーカストラップ

フォーカストラップとは、モーダルやメニューを開いた際、Tabキーによるフォーカスの移動が特定の領域内だけでループするように制限し、背景の裏側へ逃げないようにする仕組みです。

メニューを開いている間はユーザーの目的がメニュー操作に切り替わっているため、フォーカスをメニュー内でループさせる方が理にかなっています。

また、dailogパターンではフォーカスをモーダル内に閉じ込める挙動が推奨されています。

通常のドロワーメニューでは必須ではありませんが、あらかじめ実装しておけば、デザインの仕様変更でモーダルUIになった際にもそのまま流用できます。

// フォーカストラップ
function trapFocus(e) {
  if (e.key !== "Tab") return;

  const focusableSelector = `
    a[href],
    button:not([disabled]),
    input:not([disabled]),
    textarea:not([disabled]),
    select:not([disabled]),
    [tabindex]:not([tabindex="-1"])
  `;

  const focusable = [menuButton, ...menu.querySelectorAll(focusableSelector)];

  const first = focusable[0];
  const last = focusable[focusable.length - 1];

  if (!e.shiftKey && document.activeElement === last) {
    e.preventDefault();
    first.focus();
  }

  if (e.shiftKey && document.activeElement === first) {
    e.preventDefault();
    last.focus();
  }
}

メニュー内のフォーカスできる要素を取得し、Tabキーで最後まで移動したら先頭へ、先頭から戻ったら最後へ移動させます。

フォーカストラップは、メニューを開いたときにイベントを登録し、閉じたときに解除します。

function openMenu() {

  // フォーカストラップを有効化(メニュー内でフォーカスを循環)
  document.addEventListener("keydown", trapFocus);
}

function closeMenu() {

  // フォーカストラップを解除
  document.removeEventListener("keydown", trapFocus);
}

その他考慮したこと

  • PCメニューでヘッダーの高さいっぱいまでクリックできるようになっているか
  • ボタンやリンクでタップできる領域は44px以上になっているか
  • メニュー内に追加コンテンツ(SNSリンク集など)があった場合にも対応できる構造になっているか
  • スクリーンリーダーで実際に確認して適切に認識されているか

あと、「メニューを閉じた時にフォーカスをボタンに戻す動作」は採用しませんでした。

ページ内リンクをクリックして目的のコンテンツへ移動した場合、Tabキーでコンテンツ内のリンクに移動できなくなるためです。

まとめ:ヘッダー周りが最後のボス

私個人の意見として、コーダーの実力が測られる一番の部分がメニュー周りだと思っています。

動かすだけなら簡単ですが、いろいろ考慮すると記述量が多くなり、かえって不具合の元になったり、保守性が悪くなったりします。

どこまで実装するかは要件次第ですが、一度作っておけば使いわましできるので実装する価値はあると思っています。

カスタマイズに困ったらお気軽にご相談を!

  • 「ちょっとしたCSSの調整だけお願いしたい」
  • 「不具合を直してほしい」

料金は3,000円〜、お支払いは銀行振込・Amazonギフトカードなど柔軟に対応してます🤔

役に立ったら他の方にシェア

お気軽にコメントどうぞ

コメントする

目次