こんにちは、かつコーチです。
前回はDOM操作のパフォーマンスに関する注意点を扱いました。
今回からは、その知識を使って実際のUIパーツを作っていく「実践UI実装」シリーズです。
第1弾は、スマホサイトでほぼ必ず見かけるハンバーガーメニュー(3本線のアイコンをタップするとメニューが開閉する、あの部品)です。
見た目はシンプルですが、初心者がつまずきやすいポイントが詰まっているテーマでもあります。
ハンバーガーメニューとは?
なぜこの形が定番になったのか
スマホのような横幅が狭い画面では、PCサイトのようにナビゲーションを横一列に並べるスペースがありません。
そこで、普段はアイコン1つに折りたたんでおき、タップされたときだけメニューを展開する、という発想が生まれました。
3本線のアイコンが「ハンバーガー」に見えることから、この名前が定着しています。
実装の基本方針
ハンバーガーメニューの実装は、大きく分けると次の3つの要素の組み合わせです。
- HTML:アイコンボタンとメニュー本体を用意する
- CSS:メニューを「隠れた状態」で初期表示しておく
- JavaScript:ボタンがクリックされたら、CSSの状態を切り替える
つまりJavaScriptが担当するのは「開いている/閉じている」を表すクラスの付け外しだけです。
見た目の作り込み自体はCSSの役割、という役割分担を意識すると、コードがすっきりします。
基本の実装手順
手順1:HTMLを用意する
まずはボタンとメニューの土台を作ります。
<header class="header">
<p class="header__logo">かつコーチ</p>
<button class="menu-btn" id="menuBtn" aria-expanded="false" aria-controls="nav">
<span class="menu-btn__line"></span>
<span class="menu-btn__line"></span>
<span class="menu-btn__line"></span>
</button>
<nav class="nav" id="nav">
<ul class="nav__list">
<li><a href="#">ホーム</a></li>
<li><a href="#">サービス</a></li>
<li><a href="#">ブログ</a></li>
<li><a href="#">お問い合わせ</a></li>
</ul>
</nav>
</header>
aria-expanded と aria-controls は、スクリーンリーダー利用者に「このボタンは何を開閉するのか」を伝えるための属性です。
見た目には影響しませんが、UI実装では最初からセットで書く癖をつけておくと後で困りません。
手順2:CSSでメニューを隠しておく
次に、メニューが「閉じている状態」を初期状態としてCSSで定義します。
.header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #222;
color: #fff;
}
.menu-btn {
width: 32px;
height: 24px;
position: relative;
background: none;
border: none;
cursor: pointer;
}
.menu-btn__line {
position: absolute;
left: 0;
width: 100%;
height: 3px;
background: #fff;
transition: transform 0.3s, opacity 0.3s;
}
.menu-btn__line:nth-child(1) { top: 0; }
.menu-btn__line:nth-child(2) { top: 10px; }
.menu-btn__line:nth-child(3) { top: 20px; }
/* ボタンが開いた状態(is-openクラスが付いたとき) */
.menu-btn.is-open .menu-btn__line:nth-child(1) {
transform: translateY(10px) rotate(45deg);
}
.menu-btn.is-open .menu-btn__line:nth-child(2) {
opacity: 0;
}
.menu-btn.is-open .menu-btn__line:nth-child(3) {
transform: translateY(-10px) rotate(-45deg);
}
.nav {
position: absolute;
top: 100%;
left: 0;
width: 100%;
background: #333;
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.nav.is-open {
max-height: 300px;
}
.nav__list {
list-style: none;
margin: 0;
padding: 8px 0;
}
.nav__list a {
display: block;
padding: 12px 16px;
color: #fff;
text-decoration: none;
}
ポイントは、display: none ではなく max-height を使って開閉のアニメーションを付けている点です。
display: none は一瞬で表示・非表示が切り替わるため、開閉のアニメーションを付けられません。
手順3:JavaScriptでクラスを切り替える
最後に、ボタンをクリックしたときの処理を書きます。
const menuBtn = document.getElementById('menuBtn');
const nav = document.getElementById('nav');
menuBtn.addEventListener('click', () => {
const isOpen = menuBtn.classList.toggle('is-open');
nav.classList.toggle('is-open');
menuBtn.setAttribute('aria-expanded', isOpen);
});
classList.toggle() は、クラスが付いていれば外し、付いていなければ付ける、というメソッドです。
戻り値として「トグル後にクラスが付いているかどうか」を真偽値で返してくれるので、aria-expanded の更新にもそのまま使えます。
このように、if文で分岐を書かなくても1行で開閉の状態管理ができるのが toggle() の便利なところです。
つまずきやすいポイント:メニュー外クリックで閉じない
私が実際にハマった経験
私が初めてハンバーガーメニューを実装したとき、メニューを開いたあとにメニューの外側(ページの他の部分)をタップしても閉じてくれない、という状態を放置したまま公開してしまったことがあります。
PC版では気にならなかったのですが、スマホでリンク以外の場所をタップしたユーザーから「メニューが開きっぱなしで邪魔」というフィードバックをもらい、慌てて修正しました。
原因は単純で、「ボタンをクリックしたら開閉する」処理しか書いておらず、「メニューの外側をクリックしたら閉じる」処理が抜けていたことでした。
❌ Before:外側クリックへの対応がない
const menuBtn = document.getElementById('menuBtn');
const nav = document.getElementById('nav');
menuBtn.addEventListener('click', () => {
menuBtn.classList.toggle('is-open');
nav.classList.toggle('is-open');
});
// メニューを開いた後、メニュー外をクリックしても閉じない
✅ After:document にもクリックイベントを追加する
const menuBtn = document.getElementById('menuBtn');
const nav = document.getElementById('nav');
menuBtn.addEventListener('click', (event) => {
event.stopPropagation(); // documentへのクリック判定に巻き込まれないようにする
const isOpen = menuBtn.classList.toggle('is-open');
nav.classList.toggle('is-open');
menuBtn.setAttribute('aria-expanded', isOpen);
});
document.addEventListener('click', (event) => {
// メニューが開いていて、クリックした場所がnavの外側なら閉じる
const isClickInsideNav = nav.contains(event.target);
if (nav.classList.contains('is-open') && !isClickInsideNav) {
menuBtn.classList.remove('is-open');
nav.classList.remove('is-open');
menuBtn.setAttribute('aria-expanded', 'false');
}
});
ポイントは2つあります。
1つ目は document 全体にクリックイベントを仕込んでおき、nav.contains(event.target) でクリックされた場所がメニューの内側かどうかを判定していることです。
2つ目は、menuBtn 側のイベントで event.stopPropagation() を呼んでいることです。
これがないと、ボタンをクリックした瞬間に「メニューを開く処理」と「documentのクリックで閉じる処理」が同時に走ってしまい、結局メニューが開かない、という不具合になります。
私はこの stopPropagation() の存在を知らず、最初は原因が分からずかなり時間を溶かしました。
イベントが「バブリング」して親要素まで伝わっていくことを知っていれば防げたミスなので、覚えておいて損はありません。
応用:画面幅によって挙動を変える
PC表示ではメニューを常に表示する
実務では、PC幅ではハンバーガーメニューを使わず、ナビゲーションを横並びで常時表示にすることがほとんどです。
CSSのメディアクエリで、PC幅のときだけボタンを非表示にし、メニューを常時展開の状態にしておきます。
@media (min-width: 768px) {
.menu-btn {
display: none;
}
.nav {
position: static;
max-height: none;
overflow: visible;
background: none;
}
.nav__list {
display: flex;
}
}
JavaScript側は特に分岐を書かなくても、CSSで表示を切り替えるだけで対応できます。
「JavaScriptで頑張って判定する」よりも「CSSで表示を切り替え、JSはクラスの管理に専念する」という役割分担のほうが、コードがシンプルに保てます。
ESCキーで閉じられるようにする
キーボード操作への配慮として、ESCキーでメニューを閉じられるようにしておくと親切です。
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && nav.classList.contains('is-open')) {
menuBtn.classList.remove('is-open');
nav.classList.remove('is-open');
menuBtn.setAttribute('aria-expanded', 'false');
}
});
keydown イベントの event.key で押されたキーを判定できます。
こうした細かい配慮の積み重ねが、使いやすいUIとそうでないUIを分けるポイントになります。
jQueryではこう書けます(参考)
jQueryを使うと、開閉の切り替えは次のように短く書けます。
$('#menuBtn').on('click', function (e) {
e.stopPropagation();
$(this).toggleClass('is-open');
$('#nav').toggleClass('is-open');
});
toggleClass() が素のJavaScriptの classList.toggle() に相当する処理です。
jQueryの詳しい書き方は別カテゴリの記事で改めて解説します。
まとめ
この記事のポイント
- ハンバーガーメニューは「HTMLで土台」「CSSで見た目と初期状態」「JSでクラスの付け外し」という役割分担で作る
classList.toggle()を使うと開閉状態を1行で管理できる- メニュー外クリックで閉じる処理を忘れると、開きっぱなしで使いにくいUIになる
event.stopPropagation()の役割を理解しておくと、documentへのイベント登録がスムーズになる
次に読むべき記事
次回は、同じくJSの定番UIである「アコーディオン(FAQ)の開閉」を実装していきます。
クラスの付け外しという基本の考え方は今回と共通するので、理解の定着にもつながるはずです。
→ 次の記事:アコーディオン(FAQ)の開閉を実装する