【初心者向け】ハンバーガーメニューの作り方【コピペOK】

当サイトはアフィリエイト広告を使用しています。

ハンバーガーメニューは、スマホサイトではもう定番のナビゲーションです。
でも、実装となると案外やっかいなことが多くて、

  • ネットで拾ったコードが微妙に動かない
  • 開閉アニメーションがぎこちない
  • キーボード操作やスクリーンリーダーに対応していない

という状況にハマる人も多いはずです。

この記事では、案件ですぐに使える、完成済みのハンバーガーメニューのテンプレートを紹介します。
アクセシビリティ対応、レスポンシブ、フォーカス制御、ESCキー対応など、現場で求められる要素をひと通り備えています。

JavaScriptはバニラJS(純粋なJavaScript)で書かれており、jQueryには依存していません。
jQueryベースのコードを見慣れている方でも、違和感なく置き換えられる構成です。

この記事ではHTML / CSS / JavaScriptの3ファイル構成で紹介していますが、
CodePenにはSCSS / TypeScriptバージョンも用意しているので、そちらを使いたい方はそちらをどうぞ。

完成版のコード

まずは完成形のコードから紹介します。

See the Pen Drawer Templete by Suzuki Kazuma (@build_suzuki) on CodePen.

このテンプレートをそのまま使えば、アクセシビリティやレスポンシブ、キーボード操作に対応したハンバーガーメニューが実装できます。
開閉アニメーションやオーバーレイ操作も含めて、案件で必要とされる基本的な機能は一通り入っています。

記事内ではHTML / CSS / JavaScriptの3ファイル構成で解説していますが、
CodePenにはSCSS / TypeScript版も用意しています。普段使っている環境に合わせてお選びください。

HTML

ナビゲーションの構造とボタンのマークアップ

CSS

見た目のスタイル、アニメーション、表示制御など

JavaScript

開閉処理、状態管理、フォーカス制御、イベント処理

HTML

<header class="header" id="header">
  <div class="header__inner">
    <button type="button" class="drawer-button" id="drawer-button" aria-controls="drawer-nav" aria-expanded="false" aria-label="メニューを開閉">
      <span class="drawer-button__icon" aria-hidden="true">
        <span class="drawer-button__bar"></span>
        <span class="drawer-button__bar"></span>
        <span class="drawer-button__bar"></span>
      </span>
      <span class="drawer-button__text">メニュー</span>
    </button>

    <nav class="drawer-nav" id="drawer-nav" aria-label="グローバルナビゲーション" hidden>
      <div class="drawer-nav__inner">
        <ul class="drawer-nav__menu" role="list">
          <li class="drawer-nav__item"><a href="#" class="drawer-nav__link">ホーム</a></li>
          <li class="drawer-nav__item"><a href="#" class="drawer-nav__link">サービス</a></li>
          <li class="drawer-nav__item"><a href="#" class="drawer-nav__link">製品</a></li>
          <li class="drawer-nav__item"><a href="#" class="drawer-nav__link">料金</a></li>
          <li class="drawer-nav__item"><a href="#" class="drawer-nav__link">導入事例</a></li>
          <li class="drawer-nav__item"><a href="#" class="drawer-nav__link">ブログ</a></li>
          <li class="drawer-nav__item"><a href="#" class="drawer-nav__link">ヘルプ</a></li>
          <li class="drawer-nav__item"><a href="#" class="drawer-nav__link">お問い合わせ</a></li>
        </ul>
      </div>
      <div class="drawer__overlay" tabindex="0" role="button" aria-label="メニューを閉じる"></div>
    </nav>
  </div>
</header>
<!-- スクロール確認用 -->
<section></section>
<section></section>
<section></section>
<section></section>
<section></section>
<!-- スクロール確認用 -->

もしかしたら最新コードではないかもしれません。codepenの内容が最新です。

CSS

:root {
  --drawer-bar-gap: 8px;
  --duration: 300ms;
  --easing: ease;
}

.header {
  position: relative;
  z-index: 1000;
  box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16);
}

.header__inner {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  padding: 8px;
}

section {
  height: 40vh;
}

section:nth-of-type(odd) {
  background: black;
}

section:nth-of-type(even) {
  background: white;
}

.drawer-button {
  position: relative;
  z-index: 1002;
  display: flex;
  flex-direction: column;
  gap: 4px;
  align-items: center;
  justify-content: center;
  padding: 8px;
  touch-action: manipulation;
  cursor: pointer;
  background-color: transparent;
  border: none;
  border-radius: 8px;
}

.drawer-button__icon {
  position: relative;
  width: 32px;
  height: auto;
  aspect-ratio: 1;
}

.drawer-button__bar {
  position: absolute;
  top: 50%;
  left: 50%;
  display: block;
  width: 100%;
  height: 2px;
  background-color: #000;
  border-radius: 9999px;
}

.drawer-button__bar:nth-child(1) {
  transform: translate(-50%, calc(-50% - var(--drawer-bar-gap)));
  transition: transform var(--duration) var(--easing);
}

.drawer-button__bar:nth-child(2) {
  transform: translate(-50%, -50%);
  transition: opacity var(--duration) var(--easing);
}

.drawer-button__bar:nth-child(3) {
  transform: translate(-50%, calc(-50% + var(--drawer-bar-gap)));
  transition: transform var(--duration) var(--easing);
}

.drawer-button__bar:nth-child(1):where(.is-open *) {
  transform: translate(-50%, -50%) rotate(45deg);
}

.drawer-button__bar:nth-child(2):where(.is-open *) {
  opacity: 0;
}

.drawer-button__bar:nth-child(3):where(.is-open *) {
  transform: translate(-50%, -50%) rotate(-45deg);
}

.drawer-button__text {
  font-size: 10px;
  line-height: 1;
}

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

.drawer-nav {
  position: fixed;
  inset: 0;
  z-index: 1001;
  background: transparent;
}

.drawer-nav__inner {
  position: absolute;
  top: 0;
  right: 0;
  z-index: 1;
  display: flex;
  flex-direction: column;
  width: clamp(0px, 90vw, 280px);
  height: 100%;
  padding: 48px 24px;
  background-color: #fff;
  opacity: 1;
  transform: translate3d(100%, 0, 0);
  transition: transform var(--duration) var(--easing), opacity var(--duration) var(--easing);
}

.drawer-nav__inner:where(.is-open *) {
  transform: unset;
}

.is-vertical :where(.drawer-nav__inner) {
  transform: translate3d(0, 100%, 0);
}

.is-open.is-vertical :where(.drawer-nav__inner) {
  transform: unset;
}

.is-fade :where(.drawer-nav__inner) {
  opacity: 0;
}

.is-open.is-fade :where(.drawer-nav__inner) {
  opacity: 1;
}

.drawer-nav__menu {
  display: flex;
  flex: 1;
  flex-direction: column;
  gap: 8px;
  padding: 0;
  margin: 0;
  overflow: auto;
  list-style: none;
}

.drawer-nav__link {
  display: block;
  padding: 12px 16px;
  line-height: 1.4;
  color: #111;
  text-decoration: none;
}

.drawer__overlay {
  position: absolute;
  inset: 0;
  z-index: 0;
  cursor: pointer;
  background-color: rgba(0, 0, 0, 0.32);
  opacity: 0;
  transition: opacity var(--duration) var(--easing);
}

.is-open :where(.drawer__overlay) {
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  .drawer-button__bar,
.drawer-nav__inner,
.drawer__overlay {
    transition: none !important;
  }
}
.is-open :where(.drawer__overlay) {
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  .drawer-button__bar,
.drawer-nav__inner,
.drawer__overlay {
    transition: none !important;
  }
}

もしかしたら最新コードではないかもしれません。codepenの内容が最新です。

JavaScript

"use strict";
(() => {
    const root = document.documentElement; // .is-open は <html> に付与
    const btn = document.getElementById('drawer-button');
    const nav = document.getElementById('drawer-nav');
    if (!btn || !nav)
        return;
    const inner = nav.querySelector('.drawer-nav__inner');
    const overlay = nav.querySelector('.drawer__overlay');
    const labelEl = btn.querySelector('.drawer-button__text');
    if (!inner || !overlay)
        return;
    let lastFocused = null;
    let closeTimeout;
    const focusableSelector = 'a[href], area[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
    const getFocusable = () => {
        const nodes = nav.querySelectorAll(focusableSelector);
        return Array.from(nodes).filter((el) => {
            if (el === overlay)
                return true;
            const style = window.getComputedStyle(el);
            return style.display !== 'none' && style.visibility !== 'hidden';
        });
    };
    const isExpanded = () => btn.getAttribute('aria-expanded') === 'true';
    // --- スクロールロック ---
    const lockScroll = () => {
        const bs = document.body.style;
        bs.height = '100%';
        bs.overflow = 'hidden';
    };
    const unlockScroll = () => {
        const bs = document.body.style;
        bs.height = '';
        bs.overflow = '';
    };
    const open = () => {
        var _a;
        if (isExpanded())
            return;
        lastFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
        nav.hidden = false;
        btn.setAttribute('aria-expanded', 'true');
        lockScroll(); // 依頼どおり、展開時に body にstyle付与
        requestAnimationFrame(() => {
            root.classList.add('is-open');
        });
        const focusables = getFocusable();
        const target = focusables[0] || overlay;
        window.setTimeout(() => target.focus(), 10);
        document.addEventListener('keydown', onKeydown, true);
        if (labelEl) {
            const closeLabel = (_a = btn.getAttribute('data-label-close')) !== null && _a !== void 0 ? _a : '閉じる';
            labelEl.textContent = closeLabel;
        }
    };
    const close = () => {
        var _a;
        if (!isExpanded())
            return;
        btn.setAttribute('aria-expanded', 'false');
        root.classList.remove('is-open');
        const onDone = () => {
            nav.hidden = true;
            inner.removeEventListener('transitionend', onDone);
        };
        inner.addEventListener('transitionend', onDone);
        closeTimeout = window.setTimeout(onDone, 350);
        document.removeEventListener('keydown', onKeydown, true);
        if (lastFocused) {
            try {
                lastFocused.focus();
            }
            catch (_) {
                /* noop */
            }
        }
        if (labelEl) {
            const openLabel = (_a = btn.getAttribute('data-label-open')) !== null && _a !== void 0 ? _a : 'メニュー';
            labelEl.textContent = openLabel;
        }
        unlockScroll(); // 閉じたら解除
    };
    const toggle = () => {
        (isExpanded() ? close : open)();
    };
    const onKeydown = (e) => {
        if (!isExpanded())
            return;
        if (e.key === 'Escape' || e.key === 'Esc') {
            e.preventDefault();
            close();
            return;
        }
        if (e.key === 'Tab') {
            const focusables = getFocusable();
            if (focusables.length === 0)
                return;
            const first = focusables[0];
            const last = focusables[focusables.length - 1];
            const active = document.activeElement;
            if (!active)
                return;
            if (!e.shiftKey && active === last) {
                e.preventDefault();
                first.focus();
            }
            else if (e.shiftKey && active === first) {
                e.preventDefault();
                last.focus();
            }
        }
    };
    btn.addEventListener('click', toggle);
    overlay.addEventListener('click', close);
    nav.addEventListener('click', (e) => {
        const t = e.target;
        if (!t)
            return;
        const link = t.closest('.drawer-nav__link');
        if (link)
            close();
    });
    const observer = new MutationObserver(() => {
        const nowHidden = nav.hasAttribute('hidden');
        if (nowHidden) {
            root.classList.remove('is-open');
            unlockScroll();
        }
        btn.setAttribute('aria-expanded', String(!nowHidden));
    });
    observer.observe(nav, { attributes: true, attributeFilter: ['hidden'] });
    window.addEventListener('pagehide', () => {
        document.removeEventListener('keydown', onKeydown, true);
        if (closeTimeout !== undefined)
            window.clearTimeout(closeTimeout);
        observer.disconnect();
        // 念のため解除(途中離脱時のリーク防止)
        unlockScroll();
    });
})();

もしかしたら最新コードではないかもしれません。codepenの内容が最新です。

対応している仕様

このテンプレートは、「とりあえず動く」だけではなく、実際の案件で必要とされる機能や仕様にしっかり対応しています。
以下のような点をカバーしています。

アクセシビリティ

  • aria-controls / aria-expanded / aria-label を使用
  • メニューが開いているかどうかをスクリーンリーダーに明示
  • キーボードでも操作可能(Tab・Shift+Tab・Enter・Escape)

レスポンシブ対応

  • 幅の単位にvwを使用し、スマホ・タブレット・PCで自然に動作
  • メニュー幅はwidth: min(90vw, 280px)で過度に広がらないように制御
  • CSSメディアクエリなしでも実用的

アニメーションと状態管理

  • メニューは右からスライドで表示
  • ボタンの三本線は開閉時にクロスマークへ変化
  • クラスis-openをルートに付けることで、状態を統一管理
  • hidden属性をJSで制御し、DOM上から非表示にするタイミングを正確に設定

フォーカストラップ

  • メニュー内のフォーカスを循環(Shift+TabやTabで戻れない問題を防止)
  • メニューを開いた時に最初のリンクに自動フォーカス
  • 閉じると元いた場所にフォーカスを戻す

ESCキー対応

  • メニュー表示中にEscapeキーを押すと閉じる
  • モーダルなどと同様のユーザー体験

オーバーレイのクリックで閉じる

  • メニュー外の黒背景(オーバーレイ)をクリックしても閉じられる
  • スマホ利用者にとって直感的な操作

メニュー選択後の自動クローズ

  • ナビ内のリンクをクリックすると自動でメニューが閉じる
  • ページ遷移後に開いたままになることを防ぐ

また、拡張しやすさにも配慮しています。

  • ボタンのテキストは任意で、なくても動作します(削除してもエラーになりません)
  • 展開時のボタン色変更、アニメーション速度の調整、メニュー位置の変更なども簡単です
  • CSS変数や構造が整理されているため、無理なく案件に合わせたカスタマイズが可能です

構造とクラス

HTMLとCSSの構成は、保守しやすく、カスタマイズしやすいように設計されています。

クラス名はすべて BEM(Block Element Modifier)規則に従っており、要素の役割や階層が一目でわかるようになっています。
例:.drawer-button__icon, .drawer-nav__menu, .drawer-nav__item など。

状態管理は、<html> 要素に付与する .is-open クラスで一元化しています。
このクラスを基準に、開閉のアニメーションや表示切り替え、オーバーレイの制御などを行っています。

ナビゲーションには <nav> 要素を使用し、aria-labelhidden 属性も設定済み。
アクセシビリティを意識した構造で、JavaScriptとの連携もしやすくなっています。

また、ボタン・メニュー・オーバーレイといった各要素には、それぞれ独立したクラスが割り当てられているため、
見た目のカスタマイズや、他UIとの干渉を避ける対応もしやすくなっています。

JavaScriptの処理

JavaScriptは、バニラJS(純粋なJavaScript)で書いています。
jQueryなどのライブラリは使っていません。

処理の流れはシンプルですが、案件でよく求められる動きは一通り含まれています。

  • ボタンをクリックすると、メニューの開閉を切り替え
  • 開いているかどうかは .is-open クラスで管理
  • aria-expanded の切り替えでアクセシビリティにも対応
  • メニューを開いたら、最初のリンクに自動でフォーカスを移動
  • Tabキー/Shift+Tabキーでフォーカスをメニュー内に制限(フォーカストラップ)
  • Escapeキーを押すとメニューを閉じる
  • オーバーレイをクリックして閉じる処理もあり
  • メニュー内のリンクをクリックした場合も自動で閉じる
  • MutationObserver を使って、外部からメニューの表示状態が変わった場合にも、内部の状態を正しく保つ
  • ページを離れるときに、イベントの解除やタイマーのクリアを行う

テキストラベルが削除されていても動作に支障が出ないようにしてあり、
使わない要素があっても安全に動くよう配慮しています。

動作の安定性と、メンテナンスのしやすさを意識した構成です。

jQueryについて

このテンプレートでは、jQueryは使っていません
すべてバニラJavaScript(純粋なJavaScript)で実装しています。

以前は、ハンバーガーメニューの実装といえば jQuery を使うケースが多くありました。
ですが今では、querySelectorclassListaddEventListener など、
必要な処理はすべて標準のJavaScriptで書けるようになっています。

そのため、新しいプロジェクトやモダンな開発環境では、jQueryを使わずに書くことが一般的です。

なお、jQueryを読み込んでいるプロジェクトでも、このテンプレートはそのまま動作します。
jQueryとバニラJSは同時に共存できるので、無理に書き換える必要はありません。

どうしても jQuery で書きたいという場合は、
ChatGPT に「このコードを jQuery で書き直して」と依頼すれば、だいたいの変換は可能です。
ただし、バニラJSでの運用を基本とする方が、保守性・互換性の面ではおすすめです。

カスタマイズのポイント

案件によって、表示位置やサイズ、アニメーションなどを調整したい場面は多いと思います。
このテンプレートは、そうしたカスタマイズにも対応しやすい構成になっています。

表示位置の変更

初期状態では、メニューは画面右側からスライドインします。
左側から表示させたい場合は、.drawer-nav__innerleft: 0 を指定し、right を削除。
あわせて transform の方向も変更します。

メニューの幅

メニューの最大幅は width: min(90vw, 280px); で設定されています。
数値を変更すれば、より広い or 狭いレイアウトにも対応可能です。

アニメーションの速度

CSS変数 --duration を変更することで、開閉時のアニメーション速度を調整できます。
たとえば --duration: 200ms; にすれば、やや早く開閉します。

PCでも常時表示したい場合

CSSにメディアクエリを追加し、一定の画面幅以上ではナビゲーションを常時表示に切り替えることも可能です。
このときはJSによる開閉処理を無効化するなど、仕様に応じた分岐処理を追加してください。

テキストやボタンのデザイン変更

ボタンのラベル(例:「メニュー」「閉じる」)は任意で表示されます。
削除してもエラーにならないよう保護されており、プロジェクトのトーンに合わせて柔軟に変更可能です。
色やフォントサイズ、アイコンのスタイルも自由に調整できます。

このように、テンプレート全体がカスタマイズしやすい構造になっているため、デザインや要件に応じて無理なく調整できます。

まとめ

ハンバーガーメニューの実装は、簡単そうに見えて細かい部分でつまずくことが多いです。
とくに、アクセシビリティやキーボード対応、アニメーションの調整などは、案件によって求められるレベルもまちまちです。

今回紹介したテンプレートは、

  • スマホ対応(レスポンシブ)
  • アクセシビリティ対応
  • キーボード操作(Tab移動、ESCキー)
  • オーバーレイやリンククリックでの自動クローズ
  • CSSとJSが整理されていてカスタマイズしやすい構成
  • jQueryなし(ただしjQuery使用中の案件でもそのまま使える)

といった基本機能を網羅しています。

一度この形をベースにしておけば、今後の案件では都度ゼロから書かずに済みます。
デザインや仕様に合わせてスタイルを調整するだけで対応できるはずです。

使用サーバー

使用テーマ