ハンバーガーメニューがスクロールできなかった話
スマホで開くハンバーガーメニュー。
項目が多くなると、メニューの下のほうが画面からはみ出しているのにスクロールできないということが起きます。
そこで、メニューがスクロールできるようにするためのコードを書いてもらいました。
ただ、コードを貼っただけだと「なんで動いたのか」がわからないままです。
なので、よくあるハンバーガーメニューの作り方とあわせて、1行ずつ意味を整理してまとめることにしました!
今回使ったスクロール対応のコードはこちらです。
height: calc(100vh - 72px);
height: calc(100dvh - 72px);
overflow-y: auto;
overscroll-behavior-y: contain;
-webkit-overflow-scrolling: touch;
padding-bottom: calc(30px + env(safe-area-inset-bottom));
この6行の意味を解説する前に、まずはハンバーガーメニューそのものの仕組みから書いていきます。
よくあるハンバーガーメニューの作り方(HTML+CSSのみ)
ハンバーガーメニューの作り方はいくつかありますが、ここではJavaScriptを使わず、HTMLとCSSだけで作るチェックボックス方式を紹介します。
チェックボックスの「チェックが入っている/入っていない」を、メニューの「開いている/閉じている」に見立てる方法です。
HTML
<header class="header">
<a href="#" class="header-logo">sample</a>
<input type="checkbox" id="menu-toggle" class="menu-toggle">
<label for="menu-toggle" class="menu-button" aria-label="メニューを開く">
<span></span>
<span></span>
<span></span>
</label>
<nav class="menu">
<ul>
<li><a href="#">ホーム</a></li>
<li><a href="#">サービス</a></li>
<li><a href="#">料金</a></li>
<li><a href="#">よくある質問</a></li>
<li><a href="#">お問い合わせ</a></li>
</ul>
</nav>
</header>
ポイントは、input の id と label の for を同じ名前(ここでは menu-toggle)にしていることです。
こうすると、labelをタップしただけでチェックボックスのオン・オフが切り替わります。
label の中の3つの span が、ハンバーガーの三本線になります。
また、あとでCSSで「チェックが入ったらメニューを出す」という指定をするために、input・label・nav は同じ階層に、この順番で並べています。
CSS
/* ヘッダー(画面上に固定) */
.header {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 72px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
background: #fff;
box-sizing: border-box;
z-index: 100;
}
/* チェックボックス本体は見えなくする */
.menu-toggle {
display: none;
}
/* 三本線のボタン */
.menu-button {
position: relative;
width: 30px;
height: 22px;
cursor: pointer;
}
.menu-button span {
position: absolute;
left: 0;
width: 100%;
height: 2px;
background: #333;
transition: 0.3s;
}
.menu-button span:nth-child(1) { top: 0; }
.menu-button span:nth-child(2) { top: 10px; }
.menu-button span:nth-child(3) { top: 20px; }
/* メニュー(最初は隠しておく) */
.menu {
position: fixed;
top: 72px;
left: 0;
width: 100%;
background: #fff;
opacity: 0;
visibility: hidden;
transition: 0.3s;
}
/* チェックが入ったらメニューを表示 */
.menu-toggle:checked ~ .menu {
opacity: 1;
visibility: visible;
}
/* チェックが入ったら三本線を「×」にする */
.menu-toggle:checked + .menu-button span:nth-child(1) {
top: 10px;
transform: rotate(45deg);
}
.menu-toggle:checked + .menu-button span:nth-child(2) {
opacity: 0;
}
.menu-toggle:checked + .menu-button span:nth-child(3) {
top: 10px;
transform: rotate(-45deg);
}
開いたり閉じたりする仕組み
開閉を担当しているのは、:checked と、その後ろの ~ や + の記号です。
.menu-toggle:checked は「チェックが入っているときのチェックボックス」という意味です。
~ は「同じ階層で、それより後ろにある要素」、+ は「すぐ隣の要素」を指します。
つまり .menu-toggle:checked ~ .menu は、チェックが入っているときだけ、後ろにあるメニューを表示するという指定になります。
HTMLで input を label や nav より前に置いていたのは、この記号が「後ろにある要素」しか選べないためです。
三本線が「×」に変わるのも同じ仕組みで、1本目と3本目を真ん中に寄せて45度ずつ回転させ、2本目を消しています。
スクロールできなかった原因
上のコードは開閉はきちんと動きますが、メニューの項目が多いと、下のほうが画面からはみ出してしまいます。
原因は、メニューに position: fixed がついていて、高さが決まっていないことです。
position: fixed の要素は画面に貼りついているので、ページをスクロールしても一緒に動きません。
高さが決まっていないと、メニューは中身の分だけ下に伸びていきます。
画面の外にはみ出した部分は、ページのスクロールでも、メニュー自体のスクロールでも見ることができない状態になります。
なので、メニューの高さを画面の中に収めて、はみ出した分はメニューの中でスクロールさせるという直し方になります。
スクロール対応のCSSを1行ずつ解説
ここからは、冒頭の6行を .menu に足したときに、それぞれが何をしているのかを書いていきます。
【1】メニューの高さを画面内に収める(height)
height: calc(100vh - 72px);
height: calc(100dvh - 72px);
メニューの高さを画面の高さからヘッダーの高さ(72px)を引いた値にしています。
メニューはヘッダーのすぐ下(top: 72px)から始まるので、その分を引かないと、下の72px分がまた画面の外にはみ出してしまいます。
同じ height を2回書いているのには理由があります。
vh は画面の高さを基準にした単位ですが、スマホではアドレスバーが隠れたときの大きいほうの高さが基準になるようです。
そのため、アドレスバーが表示されている状態だと、100vh は実際に見えている高さより大きくなり、メニューの下端が隠れてしまいます。
dvh は、アドレスバーが出たり引っ込んだりするのに合わせて、実際に見えている高さに変わってくれる単位です。
調べた範囲では、dvh はChrome 108、Safari 15.4、Firefox 101以降で使えるようになっています。
それより古いブラウザは dvh を理解できないので2行目を読み飛ばし、1行目の vh を使います。
CSSは後から書いたものが優先されるので、この2行は必ず vh → dvh の順番で書きます(逆にすると、対応しているブラウザでも vh で上書きされてしまいます)。
【2】はみ出した分をスクロールさせる(overflow-y)
overflow-y: auto;
中身が高さを超えたときだけ、縦方向にスクロールできるようにする指定です。
今回スクロールできるようになった一番の理由は、この1行と、【1】の高さの指定の組み合わせです。
overflow-y: auto だけ書いても、高さが決まっていないとメニューが中身の分だけ伸びてしまうので、スクロールは発生しません。
高さに上限があって、はじめて「はみ出した分をスクロールする」という動きになります。
【3】後ろのページまで動かないようにする(overscroll-behavior-y)
overscroll-behavior-y: contain;
メニューを一番下までスクロールしきったあとも指を動かし続けると、その勢いが後ろのページに伝わって、ページ本体がスクロールしてしまうことがあります。
これをスクロールチェーンと呼ぶようです。
overscroll-behavior-y: contain をつけると、スクロールがメニューの中だけで止まり、後ろのページに伝わらなくなります。
あわせて、メニューの中で下に引っ張ったときにページが再読み込みされる動きも起きにくくなります。
【4】古いiPhoneでスクロールをなめらかにする(-webkit-overflow-scrolling)
-webkit-overflow-scrolling: touch;
昔のiPhone(iOS 12以前)で、指を離したあとも慣性でスーッと動くスクロールにするための指定です。
iOS 13以降は何も書かなくても慣性スクロールになるので、今はほとんど役目を終えた書き方です。
書いてあっても悪さはしないので、古い端末への保険として残すか削るかは、どちらでも問題ありません。
【5】iPhoneのホームバーに最後の項目がかぶらないようにする(padding-bottom)
padding-bottom: calc(30px + env(safe-area-inset-bottom));
メニューの一番下に余白をつけています。
env(safe-area-inset-bottom) は、iPhoneの画面の下にあるホームバー(横線)の部分の高さを受け取るための書き方です。
30pxにこの高さを足すことで、最後のメニュー項目がホームバーに重なってタップしにくくなるのを防いでいます。
ホームバーがない端末やパソコンでは値が0になるので、ただの30pxの余白として動きます。
スクロール対応を入れた完成版のCSS
基本のCSSの .menu に、ここまでの6行を足すと次のようになります。
変わっているのは .menu の部分だけで、ほかのCSSはそのままです。
.menu {
position: fixed;
top: 72px;
left: 0;
width: 100%;
background: #fff;
opacity: 0;
visibility: hidden;
transition: 0.3s;
/* ここからスクロール対応 */
height: calc(100vh - 72px);
height: calc(100dvh - 72px);
overflow-y: auto;
overscroll-behavior-y: contain;
-webkit-overflow-scrolling: touch;
padding-bottom: calc(30px + env(safe-area-inset-bottom));
box-sizing: border-box;
}
最後の box-sizing: border-box は、padding を含めた高さで計算させるための指定です。
これがないと、height に padding-bottom の分が上乗せされて、メニューが画面より少し大きくなる可能性があります。
つまずきやすいポイント
72pxはヘッダーの高さに合わせる
コードの中の72pxは、ヘッダーの高さです。 自分のサイトのヘッダーが60pxなら60pxに、というように、height の計算と top の値の両方をそろえておかないと、メニューの下がはみ出したり、すき間ができたりする可能性があります。
ホームバーの余白が効かないことがある
調べた範囲では、env(safe-area-inset-bottom) は、<meta name="viewport"> に viewport-fit=cover を入れたときに値が入る前提で使われていることが多いようです。 ただ、ブラウザや画面の向きによって挙動が違うという報告もあります。 値が0になっていても、30pxの余白は残るので表示が崩れることはありません。
メニューの外を触ると後ろのページが動くことがある
overscroll-behavior が止めてくれるのは、メニューの中でスクロールしきったときの伝わりだけです。 メニューの外側を触ったときにページが動くのを止めたい場合は、メニューが開いている間だけ body のスクロールを止める方法があります。 CSSだけでやるなら body:has(.menu-toggle:checked) { overflow: hidden; } という書き方もあります。
チェックボックス方式はキーボード操作が苦手
display: none にしたチェックボックスは、キーボードのTabキーで選べなくなるようです。 キーボード操作まで考える場合は、JavaScriptで button を使って開閉する作り方もあります。
まとめ
ハンバーガーメニューがスクロールできなかったのは、画面に固定されたメニューの高さが決まっておらず、はみ出した部分を見る手段がなかったからでした。
メニューの高さを画面の中に収めて、overflow-y: auto ではみ出した分をメニューの中でスクロールさせる、というのが直し方の中心です。
残りの行は、アドレスバーやホームバーといったスマホならではの事情に合わせるための調整でした。
コードを書いてもらったあとに1行ずつ意味を追ってみて、どの行が何のために入っているのかがわかるようになりました。
同じようにメニューがスクロールできなくて困っている人に届いたらうれしいです!


