※当サイトはアフィリエイト広告を使用しています。
ハンバーガーメニューは、スマホサイトではもう定番のナビゲーションです。
でも、実装となると案外やっかいなことが多くて、
という状況にハマる人も多いはずです。
この記事では、案件ですぐに使える、完成済みのハンバーガーメニューのテンプレートを紹介します。
アクセシビリティ対応、レスポンシブ、フォーカス制御、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の内容が最新です。
対応している仕様
このテンプレートは、「とりあえず動く」だけではなく、実際の案件で必要とされる機能や仕様にしっかり対応しています。
以下のような点をカバーしています。
アクセシビリティ
レスポンシブ対応
アニメーションと状態管理
フォーカストラップ
ESCキー対応
オーバーレイのクリックで閉じる
メニュー選択後の自動クローズ
また、拡張しやすさにも配慮しています。
構造とクラス
HTMLとCSSの構成は、保守しやすく、カスタマイズしやすいように設計されています。
クラス名はすべて BEM(Block Element Modifier)規則に従っており、要素の役割や階層が一目でわかるようになっています。
例:.drawer-button__icon
, .drawer-nav__menu
, .drawer-nav__item
など。
状態管理は、<html>
要素に付与する .is-open
クラスで一元化しています。
このクラスを基準に、開閉のアニメーションや表示切り替え、オーバーレイの制御などを行っています。
ナビゲーションには <nav>
要素を使用し、aria-label
や hidden
属性も設定済み。
アクセシビリティを意識した構造で、JavaScriptとの連携もしやすくなっています。
また、ボタン・メニュー・オーバーレイといった各要素には、それぞれ独立したクラスが割り当てられているため、
見た目のカスタマイズや、他UIとの干渉を避ける対応もしやすくなっています。
JavaScriptの処理
JavaScriptは、バニラJS(純粋なJavaScript)で書いています。
jQueryなどのライブラリは使っていません。
処理の流れはシンプルですが、案件でよく求められる動きは一通り含まれています。
テキストラベルが削除されていても動作に支障が出ないようにしてあり、
使わない要素があっても安全に動くよう配慮しています。
動作の安定性と、メンテナンスのしやすさを意識した構成です。
jQueryについて
このテンプレートでは、jQueryは使っていません。
すべてバニラJavaScript(純粋なJavaScript)で実装しています。
以前は、ハンバーガーメニューの実装といえば jQuery を使うケースが多くありました。
ですが今では、querySelector
や classList
、addEventListener
など、
必要な処理はすべて標準のJavaScriptで書けるようになっています。
そのため、新しいプロジェクトやモダンな開発環境では、jQueryを使わずに書くことが一般的です。
なお、jQueryを読み込んでいるプロジェクトでも、このテンプレートはそのまま動作します。
jQueryとバニラJSは同時に共存できるので、無理に書き換える必要はありません。
どうしても jQuery で書きたいという場合は、
ChatGPT に「このコードを jQuery で書き直して」と依頼すれば、だいたいの変換は可能です。
ただし、バニラJSでの運用を基本とする方が、保守性・互換性の面ではおすすめです。
カスタマイズのポイント
案件によって、表示位置やサイズ、アニメーションなどを調整したい場面は多いと思います。
このテンプレートは、そうしたカスタマイズにも対応しやすい構成になっています。
表示位置の変更
初期状態では、メニューは画面右側からスライドインします。
左側から表示させたい場合は、.drawer-nav__inner
に left: 0
を指定し、right
を削除。
あわせて transform
の方向も変更します。
メニューの幅
メニューの最大幅は width: min(90vw, 280px);
で設定されています。
数値を変更すれば、より広い or 狭いレイアウトにも対応可能です。
アニメーションの速度
CSS変数 --duration
を変更することで、開閉時のアニメーション速度を調整できます。
たとえば --duration: 200ms;
にすれば、やや早く開閉します。
PCでも常時表示したい場合
CSSにメディアクエリを追加し、一定の画面幅以上ではナビゲーションを常時表示に切り替えることも可能です。
このときはJSによる開閉処理を無効化するなど、仕様に応じた分岐処理を追加してください。
テキストやボタンのデザイン変更
ボタンのラベル(例:「メニュー」「閉じる」)は任意で表示されます。
削除してもエラーにならないよう保護されており、プロジェクトのトーンに合わせて柔軟に変更可能です。
色やフォントサイズ、アイコンのスタイルも自由に調整できます。
このように、テンプレート全体がカスタマイズしやすい構造になっているため、デザインや要件に応じて無理なく調整できます。
まとめ
ハンバーガーメニューの実装は、簡単そうに見えて細かい部分でつまずくことが多いです。
とくに、アクセシビリティやキーボード対応、アニメーションの調整などは、案件によって求められるレベルもまちまちです。
今回紹介したテンプレートは、
といった基本機能を網羅しています。
一度この形をベースにしておけば、今後の案件では都度ゼロから書かずに済みます。
デザインや仕様に合わせてスタイルを調整するだけで対応できるはずです。