まずはハンバーガーメニューの動きを確認する

前回の「FAQアコーディオンの記事」では、複数のボタンと回答パネルを関連付ける方法を確認しました。

今回は、その考え方を応用して、スマートフォン向けサイトでよく使われるハンバーガーメニューを作ります。

ハンバーガーメニューも、基本は「ボタンがクリックされたらHTMLの状態を変え、見た目はCSSで切り替える」という仕組みです。

一般的なスマートフォン向けサイトのハンバーガーメニューは、次のように動作します。

  1. 画面端にある三本線のボタンをクリックする
  2. 画面全体、または画面の一部を覆うメニューが表示される
  3. メニューが開いている間は、背景ページのスクロールが停止する
  4. 三本線のアイコンが「×」の形に変化する
  5. ボタンまたはメニュー内のリンクをクリックすると、メニューが閉じる

完成イメージ

右上のボタンを押してみてください。

今回作る機能を確認する

実際のサイトで使う場合はさまざまな配慮が必要ですが、最初からすべてを実装しようとすると、コードが複雑になってしまいます。

今回の基本コードでは、まず次の4つを実装します。

  • ボタンを押してメニューを開閉する
  • メニューが開いている間は背景スクロールを止める
  • メニュー内のリンクを押したら閉じる
  • aria-expandedを開閉状態に合わせて変更する

Escapeキーや画面幅変更への対応など、より実務的な処理は、記事後半の発展編で扱います。まずは基本の仕組みから作っていきましょう。

メニューの開閉で変えるものを整理する

ハンバーガーメニューでは、開閉に連動して複数の要素の状態が変わります。

今回は、ボタンが押されたら次の状態を連動して変更します。

  • メニューボタンのaria-expanded
  • 読み上げ用テキスト
  • bodyに付けるis-menu-openクラス
  • ナビゲーションの表示状態
  • 背景スクロールの可否
  • ハンバーガーアイコンの見た目

ここで重要になるのがbody要素です。

bodyは、ページ全体を包んでいるHTML要素です。今回は、メニューが開いていることを表す目印として、このbodyis-menu-openというクラスを付けます。

このクラスが付いていれば「開いている」、付いていなければ「閉じている」と判断します。

JavaScriptで複数の要素の見た目を個別に書き換えるのではなく、bodyのクラスを起点にして、実際の表示はCSSで切り替えます。

このように役割を分けると、JavaScriptの処理を増やしすぎず、後からデザインを変更しやすくなります。

この記事で学べること

この記事では、次の内容を扱います。

  • classList.togglebodyのクラスを切り替える方法
  • aria-expandedと画面上の状態を同期する方法
  • bodyのスクロールを固定する方法
  • querySelectorAllでメニュー内のリンクを取得する方法
  • リンクごとにメニューを閉じる処理を登録する方法

ハンバーガーメニューのHTMLを書く

まずは、ハンバーガーボタンとナビゲーションのHTMLを作ります。

ボタンがフォーム内に置かれた場合の誤作動を防ぐため、type="button"を付けています。

また、ボタンとメニューの関係を示すaria-controlsや、開閉状態を伝えるaria-expandedも設定します。

<header class="demo-header">
  <a class="demo-header__logo" href="#top">
    SAMPLE SITE
  </a>

  <button
    class="hamburger-btn js-hamburger-btn"
    type="button"
    aria-expanded="false"
    aria-controls="global-nav"
  >
    <span class="hamburger-btn__line"></span>
    <span class="hamburger-btn__line"></span>
    <span class="hamburger-btn__line"></span>
    <span class="sr-only js-menu-label">
      メニューを開く
    </span>
  </button>

  <nav
    id="global-nav"
    class="global-nav"
    aria-label="グローバルナビゲーション"
  >
    <ul class="global-nav__list">
      <li>
        <a href="#about" class="js-nav-link">
          私たちについて
        </a>
      </li>
      <li>
        <a href="#service" class="js-nav-link">
          サービス
        </a>
      </li>
      <li>
        <a href="#contact" class="js-nav-link">
          お問い合わせ
        </a>
      </li>
    </ul>
  </nav>
</header>

<main id="top" class="demo-main">
  <section class="demo-hero">
    <h1>ハンバーガーメニューのサンプル</h1>
    <p>
      右上の三本線をクリックすると、メニューが開きます。
    </p>
  </section>

  <section id="about" class="demo-section">
    <h2>私たちについて</h2>
    <p>
      このページは、ハンバーガーメニューの動作確認用サンプルです。
    </p>
  </section>

  <section id="service" class="demo-section">
    <h2>サービス</h2>
    <p>
      メニューを開いている間は、背景のスクロールが停止します。
    </p>
  </section>

  <section id="contact" class="demo-section">
    <h2>お問い合わせ</h2>
    <p>
      メニュー内のリンクを押すと、メニューが閉じます。
    </p>
  </section>
</main>

aria-controls="global-nav"と、ナビゲーションのid="global-nav"が一致していることがポイントです。

aria-controls="global-nav"
<nav id="global-nav">

また、button要素を使うことで、Tabキーによるフォーカスや、Enterキー・Spaceキーによる操作をブラウザが標準で提供してくれます。

ポイントaria-controlsとメニューのidを揃え、ボタンにはtype="button"を付けます。

CSSで閉じた状態と開いた状態を作る

次に、ハンバーガーボタンとメニューの見た目をCSSで作ります。

最初はメニューを非表示にしておき、bodyis-menu-openが付いたときだけ表示します。

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  color: #0f172a;
  font-family:
    -apple-system,
    BlinkMacSystemFont,
    "Segoe UI",
    "Noto Sans JP",
    sans-serif;
  line-height: 1.8;
}

button,
a {
  font: inherit;
}

.demo-main {
  overflow: hidden;
}

.demo-hero,
.demo-section {
  min-height: 100vh;
  padding: 120px 24px 80px;
}

.demo-hero {
  background: #f7faff;
}

.demo-section {
  border-top: 1px solid #e5eaf0;
  background: #fff;
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* ヘッダー */
.demo-header {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 120;
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  padding: 14px 20px;
}

.demo-header__logo {
  position: relative;
  z-index: 120;
  color: #0f172a;
  font-weight: 800;
  text-decoration: none;
}

/* ハンバーガーボタン */
.hamburger-btn {
  position: relative;
  z-index: 120;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 6px;
  width: 48px;
  height: 48px;
  border: 0;
  border-radius: 10px;
  background: #fff;
  box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
  cursor: pointer;
}

.hamburger-btn:focus-visible {
  outline: 3px solid rgba(37, 99, 235, 0.45);
  outline-offset: 3px;
}

.hamburger-btn__line {
  display: block;
  width: 24px;
  height: 2px;
  border-radius: 999px;
  background: #0f172a;
  transition:
    transform 0.3s ease,
    opacity 0.3s ease;
}

/* メニュー本体 */
.global-nav {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: grid;
  place-items: center;
  min-height: 100vh;
  height: 100dvh;
  background: rgba(15, 23, 42, 0.97);
  visibility: hidden;
  opacity: 0;
  pointer-events: none;
  transition:
    opacity 0.3s ease,
    visibility 0.3s ease;
}

.global-nav__list {
  display: grid;
  gap: 22px;
  margin: 0;
  padding: 80px 24px 24px;
  list-style: none;
  text-align: center;
}

.global-nav__list a {
  color: #fff;
  font-size: 1.5rem;
  font-weight: 700;
  text-decoration: none;
}

.global-nav__list a:hover {
  text-decoration: underline;
  text-underline-offset: 6px;
}

.global-nav__list a:focus-visible {
  outline: 3px solid #fff;
  outline-offset: 7px;
}

/* メニューが開いている状態 */
body.is-menu-open {
  overflow: hidden;
}

body.is-menu-open .global-nav {
  visibility: visible;
  opacity: 1;
  pointer-events: auto;
}

body.is-menu-open .demo-header__logo {
  color: #fff;
}

/* 三本線を×へ変化させる */
body.is-menu-open
  .hamburger-btn__line:nth-child(1) {
  transform: translateY(8px) rotate(45deg);
}

body.is-menu-open
  .hamburger-btn__line:nth-child(2) {
  opacity: 0;
}

body.is-menu-open
  .hamburger-btn__line:nth-child(3) {
  transform: translateY(-8px) rotate(-45deg);
}

@media (prefers-reduced-motion: reduce) {
  .hamburger-btn__line,
  .global-nav {
    transition: none;
  }
}

ハンバーガーボタンのz-indexは、メニュー本体より大きな値にしています。

.hamburger-btn {
  z-index: 120;
}

.global-nav {
  z-index: 100;
}

これにより、メニューが開いた後もボタンが前面に表示され、閉じる操作ができます。

JavaScriptでメニューを開閉する

JavaScriptでは、メニューの開閉状態をまとめて変更するsetMenuStateという関数を作ります。

document.addEventListener('DOMContentLoaded', () => {
  const btn =
    document.querySelector('.js-hamburger-btn');

  const navLinks =
    document.querySelectorAll('.js-nav-link');

  if (!btn) {
    return;
  }

  const menuLabel =
    btn.querySelector('.js-menu-label');

  const setMenuState = (isOpen) => {
    document.body.classList.toggle(
      'is-menu-open',
      isOpen
    );

    btn.setAttribute(
      'aria-expanded',
      String(isOpen)
    );

    if (menuLabel) {
      menuLabel.textContent = isOpen
        ? 'メニューを閉じる'
        : 'メニューを開く';
    }
  };

  btn.addEventListener('click', () => {
    const isOpen =
      document.body.classList.contains(
        'is-menu-open'
      );

    setMenuState(!isOpen);
  });

  navLinks.forEach((link) => {
    link.addEventListener('click', () => {
      setMenuState(false);
    });
  });
});

setMenuStatetrueを渡すとメニューが開き、falseを渡すと閉じる形です。

setMenuState(true);
setMenuState(false);

開閉に必要な処理を1か所へまとめておくことで、ボタンクリック、リンククリック、Escapeキーなど、複数の操作から同じ処理を利用できます。

背景のスクロールを固定する

スマートフォンで全画面メニューを開いたときに、裏側のページまでスクロールできてしまうと、操作しづらくなります。

今回のコードでは、メニューが開いたときにbodyis-menu-openを付けます。

document.body.classList.toggle(
  'is-menu-open',
  isOpen
);

CSSでは、is-menu-openが付いている間だけoverflow: hidden;を指定します。

body.is-menu-open {
  overflow: hidden;
}

これにより、メニューが開いている間は背景ページのスクロールが停止します。

今回は仕組みを分かりやすくするため、overflow: hidden;を使った基本的な背景固定を扱います。スマートフォンの環境やサイトの仕様によっては、スクロール位置を保存する方法など、より細かな対応が必要になることがあります。

完成コード

ここまでのHTML、CSS、JavaScriptを組み合わせると、次の機能を持つハンバーガーメニューが完成します。

  • ボタンによる開閉
  • 三本線から閉じるアイコンへの変化
  • 背景スクロールの固定
  • メニュー内リンクを押したときの閉じる処理
  • aria-expandedと読み上げ用テキストの更新

完成コードは、ここまでに掲載したindex.htmlstyle.cssscript.jsを組み合わせて使用します。

<header class="demo-header">
  <a class="demo-header__logo" href="#top">
    SAMPLE SITE
  </a>

  <button
    class="hamburger-btn js-hamburger-btn"
    type="button"
    aria-expanded="false"
    aria-controls="global-nav"
  >
    <span class="hamburger-btn__line"></span>
    <span class="hamburger-btn__line"></span>
    <span class="hamburger-btn__line"></span>
    <span class="sr-only js-menu-label">
      メニューを開く
    </span>
  </button>

  <nav
    id="global-nav"
    class="global-nav"
    aria-label="グローバルナビゲーション"
  >
    <ul class="global-nav__list">
      <li><a href="#about" class="js-nav-link">私たちについて</a></li>
      <li><a href="#service" class="js-nav-link">サービス</a></li>
      <li><a href="#contact" class="js-nav-link">お問い合わせ</a></li>
    </ul>
  </nav>
</header>

<main id="top" class="demo-main">
  <section class="demo-hero">
    <h1>ハンバーガーメニューのサンプル</h1>
    <p>
      右上の三本線をクリックすると、メニューが開きます。
    </p>
  </section>

  <section id="about" class="demo-section">
    <h2>私たちについて</h2>
    <p>
      このページは、ハンバーガーメニューの動作確認用サンプルです。
    </p>
  </section>

  <section id="service" class="demo-section">
    <h2>サービス</h2>
    <p>
      メニューを開いている間は、背景のスクロールが停止します。
    </p>
  </section>

  <section id="contact" class="demo-section">
    <h2>お問い合わせ</h2>
    <p>
      メニュー内のリンクを押すと、メニューが閉じます。
    </p>
  </section>
</main>
* {
  box-sizing: border-box;
}

body {
  margin: 0;
  color: #0f172a;
  font-family:
    -apple-system,
    BlinkMacSystemFont,
    "Segoe UI",
    "Noto Sans JP",
    sans-serif;
  line-height: 1.8;
}

button,
a {
  font: inherit;
}

.demo-main {
  overflow: hidden;
}

.demo-hero,
.demo-section {
  min-height: 100vh;
  padding: 120px 24px 80px;
}

.demo-hero {
  background: #f7faff;
}

.demo-section {
  border-top: 1px solid #e5eaf0;
  background: #fff;
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.demo-header {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 120;
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  padding: 14px 20px;
}

.demo-header__logo {
  position: relative;
  z-index: 120;
  color: #0f172a;
  font-weight: 800;
  text-decoration: none;
}

.hamburger-btn {
  position: relative;
  z-index: 120;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 6px;
  width: 48px;
  height: 48px;
  border: 0;
  border-radius: 10px;
  background: #fff;
  box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
  cursor: pointer;
}

.hamburger-btn:focus-visible {
  outline: 3px solid rgba(37, 99, 235, 0.45);
  outline-offset: 3px;
}

.hamburger-btn__line {
  display: block;
  width: 24px;
  height: 2px;
  border-radius: 999px;
  background: #0f172a;
  transition:
    transform 0.3s ease,
    opacity 0.3s ease;
}

.global-nav {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: grid;
  place-items: center;
  min-height: 100vh;
  height: 100dvh;
  background: rgba(15, 23, 42, 0.97);
  visibility: hidden;
  opacity: 0;
  pointer-events: none;
  transition:
    opacity 0.3s ease,
    visibility 0.3s ease;
}

.global-nav__list {
  display: grid;
  gap: 22px;
  margin: 0;
  padding: 80px 24px 24px;
  list-style: none;
  text-align: center;
}

.global-nav__list a {
  color: #fff;
  font-size: 1.5rem;
  font-weight: 700;
  text-decoration: none;
}

.global-nav__list a:hover {
  text-decoration: underline;
  text-underline-offset: 6px;
}

.global-nav__list a:focus-visible {
  outline: 3px solid #fff;
  outline-offset: 7px;
}

body.is-menu-open {
  overflow: hidden;
}

body.is-menu-open .global-nav {
  visibility: visible;
  opacity: 1;
  pointer-events: auto;
}

body.is-menu-open .demo-header__logo {
  color: #fff;
}

body.is-menu-open
  .hamburger-btn__line:nth-child(1) {
  transform: translateY(8px) rotate(45deg);
}

body.is-menu-open
  .hamburger-btn__line:nth-child(2) {
  opacity: 0;
}

body.is-menu-open
  .hamburger-btn__line:nth-child(3) {
  transform: translateY(-8px) rotate(-45deg);
}

@media (prefers-reduced-motion: reduce) {
  .hamburger-btn__line,
  .global-nav {
    transition: none;
  }
}
document.addEventListener('DOMContentLoaded', () => {
  const btn =
    document.querySelector('.js-hamburger-btn');

  const navLinks =
    document.querySelectorAll('.js-nav-link');

  if (!btn) {
    return;
  }

  const menuLabel =
    btn.querySelector('.js-menu-label');

  const setMenuState = (isOpen) => {
    document.body.classList.toggle(
      'is-menu-open',
      isOpen
    );

    btn.setAttribute(
      'aria-expanded',
      String(isOpen)
    );

    if (menuLabel) {
      menuLabel.textContent = isOpen
        ? 'メニューを閉じる'
        : 'メニューを開く';
    }
  };

  btn.addEventListener('click', () => {
    const isOpen =
      document.body.classList.contains(
        'is-menu-open'
      );

    setMenuState(!isOpen);
  });

  navLinks.forEach((link) => {
    link.addEventListener('click', () => {
      setMenuState(false);
    });
  });
});

実際に触ってみよう

完成コードは、下のCodePenでも確認できます。

右上の三本線を押して、メニューの開閉、背景スクロールの停止、メニュー内リンクを押したときの動きを確認してください。

また、開発者ツールを使い、ボタンを押したときに次の箇所が変化しているか確認してみましょう。

  • bodyis-menu-open
  • ボタンのaria-expanded
  • 読み上げ用テキスト
  • 三本線の形
  • ナビゲーションの表示状態

See the Pen Web制作JS|ハンバーガーメニュー|基本サンプル by WebToolGen (@webtoolgen) on CodePen.

コードの意味を1行ずつ見る

classList.toggle('is-menu-open', isOpen)

document.body.classList.toggle(
  'is-menu-open',
  isOpen
);

classList.toggleは、第2引数にtrueを渡すとクラスを付け、falseを渡すとクラスを外します。

今回は、開閉状態を表すisOpenの値をそのまま渡しています。

String(isOpen)

btn.setAttribute(
  'aria-expanded',
  String(isOpen)
);

aria-expandedには、文字列の"true"または"false"を設定します。

String(isOpen)を使い、JavaScriptの真偽値を明示的に文字列へ変換しています。

!isOpen

setMenuState(!isOpen);

!は、真偽値を反対の状態にします。

  • 現在がtrueならfalse
  • 現在がfalseならtrue

ボタンをクリックするたびに現在とは反対の状態へ変更するため、メニューの開閉を切り替えられます。

classList.contains

const isOpen =
  document.body.classList.contains(
    'is-menu-open'
  );

classList.containsは、指定したクラスが付いているかを確認します。

is-menu-openが付いていればtrue、付いていなければfalseを返します。

querySelectorAllforEach

const navLinks =
  document.querySelectorAll('.js-nav-link');

querySelectorAllで、メニュー内のリンクをすべて取得します。

navLinks.forEach((link) => {
  link.addEventListener('click', () => {
    setMenuState(false);
  });
});

forEachを使ってリンクを1つずつ取り出し、それぞれへクリック処理を登録します。

ここまでで基本のハンバーガーメニューは完成

ここまでで、スマートフォン向けサイトで最低限必要なハンバーガーメニューの動きが完成しました。

ただし、実際の案件では、さまざまなデバイスや操作方法を想定した対応が必要になることがあります。

ここからは、実務で追加を検討したい発展的な対応を見ていきます。

実務で追加したい発展対応

Escapeキーを押したら閉じる

キーボード操作をしているユーザーのために、メニューが開いている状態でEscapeキーを押したら閉じる処理を追加できます。

以下の処理は、基本コードのDOMContentLoaded内で、setMenuStateを定義した後に追加します。

document.addEventListener('keydown', (event) => {
  const isOpen =
    document.body.classList.contains(
      'is-menu-open'
    );

  if (event.key === 'Escape' && isOpen) {
    setMenuState(false);
    btn.focus();
  }
});

setMenuStatebtnは、基本コードと同じDOMContentLoadedのスコープにある必要があります。

メニューを閉じた後にbtn.focus()を実行することで、フォーカスを元のハンバーガーボタンへ戻します。

これにより、キーボードやスクリーンリーダーを利用しているユーザーが、次の操作位置を見失いにくくなります。

メニューの外側をクリックしたら閉じる

画面の一部だけを覆うメニューでは、暗くなった背景部分をクリックしたときにも閉じるようにすると、直感的に操作しやすくなります。

オーバーレイ用の要素を用意し、その要素がクリックされたときにsetMenuState(false)を実行します。

PC幅に戻ったときに状態をリセットする

スマートフォン表示でメニューを開いたままウィンドウ幅を広げると、is-menu-openが残ったままになることがあります。

window.matchMediaresizeイベントを使い、PC用の画面幅になったらsetMenuState(false)を実行する方法があります。

閉じたあとにフォーカスを戻す

Escapeキーや閉じるボタンでメニューを閉じた場合は、ハンバーガーボタンへフォーカスを戻すと、キーボード操作を続けやすくなります。

setMenuState(false);
btn.focus();

よくある失敗と解決法

ボタンを押してもメニューが開かない

JavaScriptで指定したクラス名と、HTMLのクラス名が一致しているか確認します。また、ブラウザの開発者ツールを開き、コンソールエラーが出ていないか確認しましょう。

class="hamburger-btn js-hamburger-btn"
document.querySelector('.js-hamburger-btn');

アイコンだけ変わってメニューが表示されない

bodyis-menu-openが付いているにもかかわらずメニューが表示されない場合は、CSSのセレクタ名やクラス名が一致しているかを確認してください。

body.is-menu-open .global-nav {
  visibility: visible;
  opacity: 1;
  pointer-events: auto;
}

メニューを閉じても背景がスクロールできない

メニューを閉じた後も、bodyis-menu-openが残っていないか確認します。このクラスが残っていると、背景固定のCSSも適用されたままになります。

<body class="is-menu-open">

body.is-menu-open {
  overflow: hidden;
}

カスタマイズして使ってみよう

右からスライドして開く形にする

メニューの閉じた状態で、画面の右側へ移動させておきます。

.global-nav {
  transform: translateX(100%);
  transition: transform 0.3s ease;
}

開いている状態でtranslateX(0)へ変更します。

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

JavaScriptを変更しなくても、CSSだけでスライド式のメニューへ変更できます。

ヘッダーを固定したまま開閉する

ヘッダーへposition: fixed;を指定し、メニュー本体とのz-indexを調整します。

ロゴやハンバーガーボタンをメニューより前面に配置すると、メニューが開いた後もヘッダーを表示できます。

アクセシビリティで確認したいこと

buttontype="button"を付ける

ハンバーガーボタンがフォーム内に置かれた場合、typeを省略すると送信ボタンとして動作する可能性があります。

<button type="button">

aria-controlsとメニューのidを揃える

ボタンがどの要素を操作するかを示すため、aria-controlsとナビゲーションのidを一致させます。

aria-controls="global-nav"
<nav id="global-nav">

aria-expandedを開閉状態に合わせる

メニューが開いているときはtrue、閉じているときはfalseにします。

aria-expanded="true"
aria-expanded="false"

画面上の見た目と属性値が一致しているか、開発者ツールで確認しましょう。

読み上げ用テキストを変更する

メニューが閉じているときは「メニューを開く」、開いているときは「メニューを閉じる」とします。

menuLabel.textContent = isOpen
  ? 'メニューを閉じる'
  : 'メニューを開く';

次回は、タブ切り替えへ

今回は、bodyのクラス切り替えを起点にして、 メニュー、アイコン、背景スクロールなど、 複数の状態を連動させるハンバーガーメニューを作りました。

ひとつの操作に合わせて複数の要素をまとめて更新する考え方は、 さまざまなUIに応用できます。

次回は、クリックしたタブに対応するパネルだけを表示する 「タブ切り替え」を作ります。

data属性を使ってタブとパネルを関連付け、 aria-selectedhiddenを 表示状態に合わせて更新する方法を確認します。