テクニック

スマホ用ハンバーガーメニューの実装方法|アクセシビリティ対応の開閉とフォーカス管理

この記事の対象: スマホ用メニューを自分で組む制作者(HTML/CSS/JSの基本が分かる人)
読了時間: 約12分

スマホのハンバーガーメニュー、見た目は作れても「開いている間に背後がスクロールする」「Escで閉じない」「Tabを押すとメニューの後ろのリンクにフォーカスが飛ぶ」——このあたりで止まっている実装をよく見かけます。どれも数行で潰せるのに、後回しにされたまま公開されている穴です。

この記事では、ライブラリを一切使わず、素のHTML/CSS/JavaScriptだけで、開閉・フォーカス管理・背景固定・Escキー・スクリーンリーダー対応まで全部入りのハンバーガーメニューを組みます。コードはそのままコピーして動く形で載せます。読み終わったときに、手元のサイトのメニューが直っている状態を目指してください。


前提と、この記事で使う環境

前提と、この記事で使う環境

動く条件

このコードは素のHTML/CSS/JavaScriptで完結します。jQueryもBootstrapも不要です。CMSに依存しないので、置き場所さえ合わせれば次のどれでも動きます。

環境 HTMLの置き場所 JSの置き場所
静的HTMLサイト index.html などの <header> 内 assets/js/nav.js を </body> 直前で読み込む
WordPress(クラシックテーマ) header.php js/nav.js を functions.php から wp_enqueue_script()
WordPress(ブロックテーマ) parts/header.html または該当テンプレート 同上(functions.php からenqueue)
Shopify(Online Store 2.0) sections/header.liquid assets/nav.js を theme.liquid から読み込む

WordPressで子テーマを使っていない場合は、テーマ更新で消えます。先に子テーマを用意してください。

使っているブラウザ機能と対応状況

機能 用途 対応状況の目安
:has() 使わない(あえて避けた) —
inert 属性 背後の要素を操作不能にする 主要モダンブラウザで対応済み。古い環境向けフォールバックあり
element.focus({ preventScroll: true }) フォーカス時の勝手なスクロール抑止 主要モダンブラウザで対応済み
matchMedia PC幅に戻ったときの自動クローズ 全モダンブラウザで対応済み
CSS dvh 単位 モバイルのアドレスバー対策 主要モダンブラウザで対応済み。非対応環境向けに vh との併記を推奨

対応バージョンの詳細は変わりやすいので、実装前に Can I use で最新の状況を確認してください。inert が効かない古い環境向けのフォールバックもコードに含めてあります。対応状況を自分で確認したいときは、ブラウザのコンソールで 'inert' in HTMLElement.prototype と打てば true/false が返ります。


まず、よくある実装の穴を4つ確認する

作り始める前に、何を潰すのかを先に見ておきます。これが分かっていないと、コードを貼っても「なぜこの行が要るのか」が分からなくなります。

ハンバーガーメニューで落ちやすい4か所

開閉の状態

  • ボタンに aria-expanded が無い
  • display:none と visibility の使い分けが逆

背景のスクロール

  • overflow:hidden だけでiOSが止まらない
  • 閉じたときスクロール位置が先頭に戻る

キーボード操作

  • Tabがメニューの外へ抜ける
  • Escで閉じない
  • 閉じたあとフォーカスが迷子になる

画面幅の変化

  • 横向きにしたらメニューが開いたまま固まる

下の実装は、この4つをそれぞれ別の関数で潰していきます。

特に厄介なのが3つ目です。見た目の上ではメニューが画面全体を覆っていても、DOM上は背後のリンクが生きたままなので、Tabキーを押すと画面に見えていない場所にフォーカスが移動します。キーボードだけで操作している人には、フォーカスが消えたようにしか見えません。


HTMLを書く

マークアップの全文

まずHTMLです。<header> の中にそのまま貼ってください。

<header class="site-header">
  <div class="site-header__inner">
    <a class="site-header__logo" href="/">サイト名</a>

    <button
      type="button"
      class="nav-toggle"
      id="navToggle"
      aria-expanded="false"
      aria-controls="globalNav"
    >
      <span class="nav-toggle__bars" aria-hidden="true">
        <span></span><span></span><span></span>
      </span>
      <span class="nav-toggle__label">メニュー</span>
    </button>
  </div>

  <div class="nav-overlay" id="navOverlay" hidden></div>

  <nav class="global-nav" id="globalNav" aria-label="メインメニュー" hidden>
    <ul class="global-nav__list">
      <li><a href="/about/">私たちについて</a></li>
      <li><a href="/service/">サービス</a></li>
      <li><a href="/works/">制作実績</a></li>
      <li><a href="/blog/">ブログ</a></li>
      <li><a href="/contact/">お問い合わせ</a></li>
    </ul>
    <button type="button" class="global-nav__close" id="navClose">
      閉じる
    </button>
  </nav>
</header>

<main id="mainContent">
  <!-- ページの本文 -->
</main>

<footer id="siteFooter">
  <!-- フッター -->
</footer>

コピーしたあと変更する箇所

変更する箇所 何に変えるか
サイト名 と href="/" 自分のサイト名とトップページURL
<li> の5項目 実際のメニュー項目とURL
id="mainContent" / id="siteFooter" 既存のマークアップに合わせる。IDが無ければ付ける
クラス名の接頭辞 nav- global-nav 既存のCSS設計に衝突があれば全置換する

なぜこう書くのか

<button type="button"> を使う理由。 <a href="#"> や <div> でメニューボタンを作っている実装が多いですが、<button> ならキーボードのEnterとSpaceが最初から効き、スクリーンリーダーが「ボタン」と読み上げます。<div> にすると tabindex="0" と role="button" とキーイベントを自分で書く羽目になり、書き漏らしが必ず出ます。type="button" を明示するのは、フォームの中に置かれたときに送信ボタンとして誤動作しないためです。

aria-expanded と aria-controls。 aria-expanded は開閉状態そのもので、JSで true/false を書き換えます。スクリーンリーダーはこれを読んで「折りたたみ、展開済み」などと伝えます。aria-controls はどの要素を制御しているかの紐付けで、値は制御対象の id と完全一致させます。

hidden 属性を初期値にする。 JSが読み込まれる前の一瞬、メニューが全開で表示される——いわゆるチラつきを防ぎます。CSSの display:none だけに頼ると、CSSの読み込み順やキャッシュ次第でチラつきます。HTML属性なら確実です。

ナビの中に「閉じる」ボタンを置く。 後述のフォーカストラップで、Shift+Tab の折り返し地点として機能します。見た目上不要でも、置いておくとキーボード操作が素直になります。


CSSを書く

スタイルの全文

assets/css/nav.css として保存するか、既存のCSSの末尾に追記してください。

/* ============================================
   ハンバーガーメニュー
   ============================================ */

.site-header {
  position: sticky;
  top: 0;
  z-index: 100;
  background: #fff;
  border-bottom: 1px solid #e5e5e5;
}

.site-header__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  max-width: 1200px;
  margin-inline: auto;
  padding: 12px 16px;
}

.site-header__logo {
  font-size: 18px;
  font-weight: 700;
  text-decoration: none;
  color: #111;
}

/* --- トグルボタン --- */
.nav-toggle {
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  padding: 8px;
  background: none;
  border: 0;
  cursor: pointer;
  color: #111;
}

.nav-toggle:focus-visible {
  outline: 2px solid #111;
  outline-offset: 2px;
  border-radius: 4px;
}

.nav-toggle__bars {
  display: block;
  width: 24px;
  height: 16px;
  position: relative;
}

.nav-toggle__bars span {
  display: block;
  position: absolute;
  left: 0;
  width: 100%;
  height: 2px;
  background: currentColor;
  transition: transform .25s ease, opacity .2s ease;
}

.nav-toggle__bars span:nth-child(1) { top: 0; }
.nav-toggle__bars span:nth-child(2) { top: 7px; }
.nav-toggle__bars span:nth-child(3) { top: 14px; }

.nav-toggle[aria-expanded="true"] .nav-toggle__bars span:nth-child(1) {
  transform: translateY(7px) rotate(45deg);
}
.nav-toggle[aria-expanded="true"] .nav-toggle__bars span:nth-child(2) {
  opacity: 0;
}
.nav-toggle[aria-expanded="true"] .nav-toggle__bars span:nth-child(3) {
  transform: translateY(-7px) rotate(-45deg);
}

.nav-toggle__label {
  font-size: 10px;
  letter-spacing: .04em;
}

/* --- オーバーレイ --- */
.nav-overlay {
  position: fixed;
  inset: 0;
  z-index: 90;
  background: rgba(0, 0, 0, .45);
  opacity: 0;
  transition: opacity .25s ease;
}

.nav-overlay[hidden] {
  display: none;
}

.nav-overlay.is-visible {
  opacity: 1;
}

/* --- ナビ本体 --- */
.global-nav {
  position: fixed;
  top: 0;
  right: 0;
  z-index: 95;
  width: min(86vw, 360px);
  height: 100dvh;
  padding: 72px 24px 32px;
  background: #fff;
  overflow-y: auto;
  overscroll-behavior: contain;
  transform: translateX(100%);
  transition: transform .28s ease;
  box-shadow: -8px 0 24px rgba(0, 0, 0, .12);
}

.global-nav[hidden] {
  display: none;
}

.global-nav.is-open {
  transform: translateX(0);
}

.global-nav__list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.global-nav__list li + li {
  border-top: 1px solid #eee;
}

.global-nav__list a {
  display: block;
  padding: 16px 4px;
  font-size: 16px;
  color: #111;
  text-decoration: none;
}

.global-nav__list a:focus-visible,
.global-nav__close:focus-visible {
  outline: 2px solid #111;
  outline-offset: -2px;
}

.global-nav__close {
  margin-top: 32px;
  padding: 12px 20px;
  font-size: 14px;
  background: #f4f4f4;
  border: 0;
  border-radius: 4px;
  cursor: pointer;
}

/* --- 背景固定用(bodyに付ける) --- */
body.is-nav-open {
  position: fixed;
  width: 100%;
  overflow: hidden;
}

/* --- PC幅ではハンバーガーを使わない --- */
@media (min-width: 900px) {
  .nav-toggle,
  .nav-overlay {
    display: none !important;
  }

  .global-nav {
    position: static;
    width: auto;
    height: auto;
    padding: 0;
    transform: none;
    box-shadow: none;
    overflow: visible;
  }

  .global-nav[hidden] {
    display: block;
  }

  .global-nav__list {
    display: flex;
    gap: 24px;
  }

  .global-nav__list li + li {
    border-top: 0;
  }

  .global-nav__close {
    display: none;
  }
}

/* --- アニメーションを減らす設定への配慮 --- */
@media (prefers-reduced-motion: reduce) {
  .global-nav,
  .nav-overlay,
  .nav-toggle__bars span {
    transition: none;
  }
}

変更する箇所

  • 色(#111 #fff #e5e5e5 rgba(0,0,0,.45))をサイトのカラーに合わせる
  • z-index の 100 / 95 / 90 は、既存のヘッダーやモーダルより上になるよう調整する
  • ブレイクポイント 900px を、自分のデザインの切り替え幅に変える(JS側にも同じ値を書くので両方直す)
  • width: min(86vw, 360px) でドロワーの幅を調整する

押さえておきたい3点

100dvh を使う。 100vh だとiOSのSafariでアドレスバーの高さぶんはみ出し、メニュー下部のリンクが指で届かない位置に落ちます。dvh(dynamic viewport height)はアドレスバーの伸縮に追従します。古い環境も拾うなら height: 100vh; height: 100dvh; と2行重ねて書けば、dvh を解釈できないブラウザは1行目を採用します。

overscroll-behavior: contain。 ドロワー内をスクロールしきったとき、その勢いで背後のページがスクロールし始める「スクロールチェーン」を止めます。これは後述のJS側の背景固定とは別の話で、両方入れておく必要があります。

PC幅で [hidden] を打ち消している。 HTMLに hidden を付けたままなので、そのままだとPC幅でもナビが消えます。メディアクエリ内で display: block に戻すのがポイントです。hidden 属性のデフォルトスタイルは display: none ですが、これは作者スタイルシートで上書きできます。


JavaScriptを書く

JavaScriptを書く

ここが本題です。assets/js/nav.js として保存してください。

スクリプトの全文

(function () {
  'use strict';

  var toggle   = document.getElementById('navToggle');
  var nav      = document.getElementById('globalNav');
  var overlay  = document.getElementById('navOverlay');
  var closeBtn = document.getElementById('navClose');

  // 背後に回す要素。ここにあるものは、メニューが開いている間
  // キーボードでもスクリーンリーダーでも触れなくなる
  var backgroundIds = ['mainContent', 'siteFooter'];

  // CSSのメディアクエリと同じ値にすること
  var DESKTOP_QUERY = '(min-width: 900px)';

  if (!toggle || !nav || !overlay) return;

  var isOpen = false;
  var scrollY = 0;
  var supportsInert = 'inert' in HTMLElement.prototype;

  var FOCUSABLE = [
    'a[href]',
    'button:not([disabled])',
    'input:not([disabled]):not([type="hidden"])',
    'select:not([disabled])',
    'textarea:not([disabled])',
    '[tabindex]:not([tabindex="-1"])'
  ].join(',');

  function backgroundElements() {
    return backgroundIds
      .map(function (id) { return document.getElementById(id); })
      .filter(function (el) { return el !== null; });
  }

  function setBackgroundInert(on) {
    backgroundElements().forEach(function (el) {
      if (supportsInert) {
        el.inert = on;
      }
      // inert 非対応環境と、読み上げ対策の保険
      if (on) {
        el.setAttribute('aria-hidden', 'true');
      } else {
        el.removeAttribute('aria-hidden');
      }
    });
  }

  function lockScroll() {
    scrollY = window.scrollY || window.pageYOffset;
    document.body.style.top = '-' + scrollY + 'px';
    document.body.classList.add('is-nav-open');
  }

  function unlockScroll() {
    document.body.classList.remove('is-nav-open');
    document.body.style.top = '';
    window.scrollTo(0, scrollY);
  }

  function focusableInNav() {
    return Array.prototype.slice
      .call(nav.querySelectorAll(FOCUSABLE))
      .filter(function (el) {
        return el.offsetWidth > 0 || el.offsetHeight > 0 ||
               el.getClientRects().length > 0;
      });
  }

  function open() {
    if (isOpen) return;
    isOpen = true;

    nav.hidden = false;
    overlay.hidden = false;
    // hidden を外した直後だと transition が効かないので
    // 次のフレームでクラスを付ける
    requestAnimationFrame(function () {
      nav.classList.add('is-open');
      overlay.classList.add('is-visible');
    });

    toggle.setAttribute('aria-expanded', 'true');
    lockScroll();
    setBackgroundInert(true);

    var items = focusableInNav();
    if (items.length) {
      items[0].focus({ preventScroll: true });
    }
  }

  function close(returnFocus) {
    if (!isOpen) return;
    isOpen = false;

    nav.classList.remove('is-open');
    overlay.classList.remove('is-visible');
    toggle.setAttribute('aria-expanded', 'false');

    setBackgroundInert(false);
    unlockScroll();

    // アニメーションが終わってから hidden に戻す
    window.setTimeout(function () {
      if (!isOpen) {
        nav.hidden = true;
        overlay.hidden = true;
      }
    }, 300);

    if (returnFocus !== false) {
      toggle.focus({ preventScroll: true });
    }
  }

  function trapFocus(e) {
    if (!isOpen || e.key !== 'Tab') return;

    var items = focusableInNav();
    if (!items.length) {
      e.preventDefault();
      return;
    }

    var first = items[0];
    var last  = items[items.length - 1];
    var active = document.activeElement;

    if (e.shiftKey) {
      if (active === first || !nav.contains(active)) {
        e.preventDefault();
        last.focus({ preventScroll: true });
      }
    } else {
      if (active === last || !nav.contains(active)) {
        e.preventDefault();
        first.focus({ preventScroll: true });
      }
    }
  }

  // --- イベント登録 ---

  toggle.addEventListener('click', function () {
    isOpen ? close() : open();
  });

  overlay.addEventListener('click', function () {
    close();
  });

  if (closeBtn) {
    closeBtn.addEventListener('click', function () {
      close();
    });
  }

  document.addEventListener('keydown', function (e) {
    if (e.key === 'Escape' && isOpen) {
      e.preventDefault();
      close();
    }
    trapFocus(e);
  });

  // ナビ内のリンクを踏んだら閉じる(アンカーリンク対策)
  nav.addEventListener('click', function (e) {
    var link = e.target.closest('a[href]');
    if (link && isOpen) {
      close(false);
    }
  });

  // PC幅に戻ったら強制的に閉じる
  var mql = window.matchMedia(DESKTOP_QUERY);
  function handleBreakpoint(e) {
    if (e.matches && isOpen) {
      close(false);
      nav.hidden = false;
      overlay.hidden = true;
    }
  }
  if (mql.addEventListener) {
    mql.addEventListener('change', handleBreakpoint);
  } else {
    mql.addListener(handleBreakpoint); // 古いSafari向けフォールバック
  }

  // ブラウザバックでキャッシュから復帰したときの取り残し防止
  window.addEventListener('pageshow', function (e) {
    if (e.persisted && isOpen) {
      close(false);
    }
  });
})();

読み込み方

静的サイトなら </body> の直前に置きます。

<script src="/assets/js/nav.js" defer></script>

WordPressの子テーマなら functions.php に次を追記します。

add_action( 'wp_enqueue_scripts', function () {
    wp_enqueue_script(
        'site-nav',
        get_stylesheet_directory_uri() . '/js/nav.js',
        array(),
        filemtime( get_stylesheet_directory() . '/js/nav.js' ),
        true
    );
} );

第4引数に filemtime() を使っているのは、ファイルを更新したときにキャッシュを自動で切るためです。バージョン番号を手で書き換える運用はいずれ忘れます。

Shopify(Online Store 2.0)なら assets/nav.js に置いて、layout/theme.liquid の </body> 直前に次の1行を入れます。

<script src="{{ 'nav.js' | asset_url }}" defer></script>

背景スクロール固定の仕組みを理解しておく

背景スクロール固定の仕組みを理解しておく

ここは事故が多いので、動きを分解しておきます。

背景固定の2つのやり方の違い

position:fixed + top補正(採用)

  • iOS Safariでも確実に止まる
  • スクロール位置を自前で復元する
  • 閉じたとき元の位置に戻る

overflow:hidden だけ

  • 記述は1行で済む
  • iOSで止まらないことがある
  • ページによっては効いたり効かなかったりする

body { overflow: hidden } だけでは、iOS Safariで背後がスクロールしてしまうケースがあります。確実に止めるには position: fixed を使いますが、これを付けた瞬間にページが先頭へ飛ぶという副作用が出ます。だから開く直前に window.scrollY を控えておき、body の top にマイナス値で当てて見た目の位置を維持し、閉じるときに window.scrollTo() で戻します。上のコードの lockScroll() / unlockScroll() がその処理です。

この3点セット(位置の保存・topのマイナス補正・復元)はどれか1つでも欠けると破綻します。「閉じたらページ先頭に戻ってしまう」という相談の多くは、unlockScroll() の window.scrollTo(0, scrollY) が抜けているパターンです。


フォーカス管理でやっていること

キーボード操作まわりは、4つの処理が組み合わさっています。

処理 該当コード 何が起きるか
開いたら最初の項目へ移動 items[0].focus() 開いた直後にTabを押すと、いきなりメニューの先頭が選ばれる
Tabを閉じ込める trapFocus() 最後の要素から先へ進もうとすると先頭へ戻る
背後を操作不能にする setBackgroundInert() 本文とフッターのリンクがタブ順から消える
閉じたらボタンへ戻す toggle.focus() 閉じた瞬間にフォーカスが迷子にならない

3つ目の inert が特に効きます。属性を1つ付けるだけで、その要素以下のフォーカス・クリック・読み上げが全部無効になります。inert 非対応の環境向けに aria-hidden="true" も同時に付けていますが、これは読み上げを止めるだけでフォーカスは止まりません。完全なフォールバックが必要なら、背後の要素すべてに tabindex="-1" を付けて回す処理を書き足すことになります。ただしそこまでやるコストと、対象ブラウザのシェアを天秤にかけて判断してください。

focus({ preventScroll: true }) を全箇所で使っている理由。 素の focus() はフォーカス先を画面内に入れようとしてスクロールを起こします。body を position: fixed にしている最中にこれが走ると、レイアウトが一瞬跳ねます。preventScroll: true で止めておくのが安全です。


動作確認のやり方

貼り終えたら、この順で確認してください。マウスだけで確認すると、キーボードの穴は絶対に見つかりません。

公開前に踏む確認手順
1. マウスで開閉ボタン・オーバーレイ・閉じるの3経路
2. キーボードだけTab・Enter・Escで一周する
3. 実機のiPhone背景が動かないか指で確認
4. 幅を変える開いたまま横向きにする

キーボードだけのチェックリスト

マウスから手を離して、次を全部やります。

  • Tabでハンバーガーボタンにフォーカスが当たり、枠が見える(:focus-visible が効いているか)
  • Enterで開く。Spaceでも開く
  • 開いた直後、Tabを押すとメニュー内の2番目の項目に進む
  • メニューの最後(閉じるボタン)でTabを押すと、先頭のリンクに戻る
  • Shift+Tab でも同様に閉じ込められる
  • Escで閉じる
  • 閉じた直後、フォーカスがハンバーガーボタンに戻っている

1つでも外れたら、該当する関数を見直してください。

スマホ実機での確認

シミュレータやDevToolsのデバイスモードでは、iOSのスクロール挙動が再現されません。必ず実機で開き、メニューが開いている状態で背景を指で上下にこすってください。1ピクセルでも動いたら lockScroll() が効いていません。


ギャラリー掲載サイトを見るときのチェック観点

ギャラリー掲載サイトを見るときのチェック観点

見た目のデザインだけを参考にすると、上で潰した穴をそのまま持ち込んでしまうことがあります。他サイトのメニューを見るときは、次の3点を実際に手を動かして確認してください。

1. 閉じるボタンの位置。 ドロワーの右上に「×」を置く実装が定番ですが、縦長のスマホだと親指が届きにくくなります。メニュー項目の下、スクロールの終点に「閉じる」を置く設計なら、片手操作を意識している可能性が高いです。上のコードでも、閉じるボタンをナビの末尾に置いているのは同じ考え方です。

2. オーバーレイの透明度。 背景を真っ黒に近く落とすと、「後ろにコンテンツがある」という文脈が消えます。背景がうっすら透けて見える不透明度になっていれば、「メニューを閉じれば戻れる」ことが視覚的に伝わりやすくなります。

3. アニメーションの速さ。 メニューは「使う」ためのUIなので、開閉が長すぎると操作の邪魔になります。体感で0.2〜0.3秒程度に収まっているかを目安にしてください。上のCSSで .28s にしているのはこの範囲です。

ギャラリーを見るときは、デザインだけでなくブラウザの幅を変えたり、Tabキーを押したりしながら確認してみてください。見た目の参考にするサイトが、挙動まで参考にできるとは限りません。逆に、地味に見えるサイトほど挙動が丁寧なこともあります。


うまくいかないとき

メニューが一瞬だけ全表示されてから消える

HTMLの hidden 属性が抜けています。<nav> と <div class="nav-overlay"> の両方に付いているか確認してください。CSSだけで隠している場合、CSSの読み込みが遅れた瞬間に全部見えます。

PC幅でメニューが消えたまま出てこない

メディアクエリ内の .global-nav[hidden] { display: block; } が抜けています。hidden 属性のデフォルトスタイルを打ち消す必要があります。加えて、CSSの 900px とJSの DESKTOP_QUERY の値がズレていないかも見てください。片方だけ直したケースが非常に多いです。

閉じたらページの先頭に戻ってしまう

unlockScroll() の中の window.scrollTo(0, scrollY) が実行されていません。scrollY 変数が関数のスコープ外で宣言されているか(上のコードではIIFE直下)を確認してください。open() の中でローカル変数として宣言すると、close() から見えなくなります。

iOSで背景がスクロールしてしまう

3つ確認します。

  1. body.is-nav-open のCSSに position: fixed が入っているか
  2. lockScroll() が open() の中で呼ばれているか
  3. ドロワー要素に overscroll-behavior: contain が付いているか

3つ目が抜けていると、ドロワー内をスクロールしきった勢いだけが背景に伝わります。

Tabキーでメニューの外に出てしまう

trapFocus() が document の keydown に登録されているか見てください。nav 要素に登録すると、フォーカスがナビの外へ出た瞬間にイベントが拾えなくなり、二度と戻ってこられません。上のコードで document.addEventListener にしているのはこのためです。

もう1つ、FOCUSABLE のセレクタに漏れがあるケースもあります。ナビの中にカスタム要素や tabindex を持つ div を入れているなら、セレクタに追加してください。

Escキーが効かない

e.key === 'Escape' の綴りを確認します。古い記事では 'Esc' と書かれていることがありますが、現行の仕様は 'Escape' です。e.keyCode === 27 は非推奨なので使わないでください。

また、<input> などにフォーカスがある状態だと、IMEの変換中にEscが吸われることがあります。メニュー内に検索フォームを置いている場合は、この挙動を織り込んでおいてください。

ブラウザバックで戻ったらメニューが開いたまま

pageshow イベントの処理が入っているか確認してください。Safariのbfcache(戻る操作で状態ごと復元するキャッシュ)から復帰すると、body の position: fixed が残ったままページが表示されます。上のコードの最後にある window.addEventListener('pageshow', ...) がこれを解除します。

アンカーリンクを押しても何も起きない

ページ内リンク(#section など)はページ遷移が起きないので、メニューが開いたままです。上のコードでは nav.addEventListener('click', ...) でリンククリックを拾って閉じています。この処理が抜けていると、リンクを押した先が見えません。

なお、閉じるときに close(false) を渡してフォーカスをボタンに戻していないのは、遷移先で改めてフォーカスが移るのが自然だからです。


よくある質問

ハンバーガーメニューにアクセシビリティ対応は本当に必要ですか

必要です。キーボードだけで操作している人にとって、フォーカスが背後に抜けるメニューは実質的に閉じられない罠になります。この記事の実装で追加している属性と処理はそれほど多くないので、コストに対して効果が大きい部類です。

aria-hidden と inert はどちらを使えばいいですか

両方です。inert はフォーカスもクリックも読み上げも止めますが、対応していない環境では何も起きません。aria-hidden="true" は読み上げだけを止めるので、inert の保険として併記します。ただし aria-hidden はフォーカスを止めないため、これ単体では不十分です。

ライブラリを使わずに書く理由は何ですか

読み込みが増えるぶん表示速度に効くからです。ハンバーガーメニューはページの最上部にあり、多くの場合ファーストビューに含まれます。ここでJSライブラリの読み込みを待たせると、体感速度が直接落ちます。この記事のコードは軽量なので、ライブラリを入れる理由がほぼありません。

スマホだけでなくタブレットでもハンバーガーにすべきですか

デザインとメニュー項目数の問題なので一概には言えませんが、判断の基準は「横並びにしたとき、項目が2段に折り返さずに収まるか」です。折り返すならハンバーガーに切り替えたほうが読みやすくなります。切り替え幅は 900px 前後から試して、実機で調整してください。

既存サイトのメニューを差し替えるとき、何に気をつけますか

クラス名の衝突と z-index の2つです。既存のCSSに同じクラス名があると、意図しないスタイルが混ざります。作業前に nav-toggle や global-nav でプロジェクト全体を検索して、ヒットしたら接頭辞を変えてください。z-index は、既存のモーダルや固定バナーより上になっているかを実機で確認します。


まとめ

ハンバーガーメニューは「開いて閉じるだけ」のUIに見えますが、実際に手を入れるのは次の5か所です。

項目 対応
開閉状態の伝達 aria-expanded をJSで切り替える
背景スクロール position: fixed + スクロール位置の保存と復元
フォーカスの閉じ込め keydown を document で拾ってTabを折り返す
背後の無効化 inert + aria-hidden の併用
画面幅の変化 matchMedia で強制クローズ

どれも外部ライブラリなしで済みます。この記事のコードをそのまま置けば、上の5つは全部埋まった状態から始められます。あとは色とブレイクポイントを自分のデザインに合わせて、キーボードだけで一周してみてください。

CMS側のテンプレート実装で手が止まったときは、下の記事も合わせてどうぞ。