読者右側の目次が長くなっても、ページからはみ出さずにスクロールできるのは、どんな仕組みですか?
木村position: stickyで目次の外枠を追従させ、内側のnavへ高さの上限とoverflow-y: autoを指定しています。ページ全体と目次を別々のスクロール領域にするのがポイントです。
読者スマホのハンバーガーメニューも、項目が多いときは同じ考え方でよいのでしょうか?
木村基本は同じです。メニューの高さを100dvhからヘッダー分だけ引き、メニュー内をスクロール可能にします。開いている間は本文側のスクロールを止めます。
読者CSSだけでなく、現在地の表示やメニューの開閉も一緒に理解したいです。
木村この記事では、HTMLを出力するPHP、見た目とスクロール領域を作るCSS、状態を切り替えるJavaScriptの順に確認します。最後にアクセシビリティとつまずきやすい点も整理します。
結論:スクロールする領域を分けて設計する
結論からいうと、長い目次とハンバーガーメニューは、どちらも「外側で配置を決め、内側だけをスクロールさせる」と実装しやすくなります。ページ全体のスクロールへ任せるのではなく、表示できる高さを先に決め、その範囲を超えた内容だけにスクロールバーを付けます。
ページ全体と内部スクロールの役割
PCの右側目次では、外枠をstickyで追従させ、内側のnavへoverflow-y: autoを指定します。スマホメニューでは、メニュー自体へ画面内に収まる高さとoverflow: autoを指定し、本文のbodyは一時的に固定します。
実装で押さえる4つのポイント
- 追従目次は
position: stickyとtopで表示位置を固定する - 内部スクロールには高さの上限と
overflow-y: autoをセットで指定する - スマホメニューは
100dvhを基準にし、開いている間は背面を固定する - JavaScriptで現在地、開閉状態、
aria-expandedを同期する
WordPressテーマで使うHTML構造
最初に、CSSとJavaScriptが操作する要素をテーマテンプレートから出力します。目次はsidebar.php、ハンバーガーメニューはheader.phpへ置くと役割を分けやすくなります。
目次のリストはJavaScriptで生成するため、PHP側には外枠、見出し、ナビゲーション、空のリストを用意します。hiddenを付けておけば、見出しがない記事やJavaScriptが動作しない途中状態で空の枠が表示されません。
<?php
// 投稿ページの右側へ、JavaScriptで生成する追従目次の枠を表示する。
if ( is_singular( 'post' ) ) :
?>
<section class="sidebar__section article-toc"
data-article-toc hidden>
<h2>目次</h2>
<nav aria-label="追従目次">
<ol class="article-toc__list"
data-article-toc-list></ol>
</nav>
</section>
<?php endif; ?>header.phpへ開閉ボタンとメニューを置く
ボタンのaria-controlsとナビゲーションのidを一致させます。視覚的な3本線とは別に、読み上げ用のテキストを用意すると、開くボタンなのか閉じるボタンなのかを伝えられます。
<?php
// スマートフォンの主要カテゴリーを開閉するボタンとナビゲーションを表示する。
?>
<button class="menu-toggle" type="button"
aria-expanded="false"
aria-controls="site-navigation">
<span></span><span></span><span></span>
<span class="screen-reader-text">メニューを開く</span>
</button>
<nav id="site-navigation" class="site-nav"
aria-label="主要カテゴリー">
<?php
wp_nav_menu(
array(
'theme_location' => 'primary',
'container' => false,
)
);
?>
</nav>右側の追従目次をCSSでスクロールさせる
右側目次では、外枠と内側ナビゲーションへ別々の役割を与えます。外枠は追従位置と全体の上限、内側は実際のスクロールを担当します。
stickyで目次の外枠を追従させる
topには固定・追従ヘッダーの高さより少し大きい値を指定します。WebCantaではヘッダーとの間に余白を持たせるため、10.8remを使用しています。
max-heightとoverflow-yをセットで指定する
overflow-y: autoだけでは、要素の高さが内容に合わせて伸び続ける場合があります。スクロールさせる要素には、必ず高さまたは高さの上限も指定します。
/* 投稿ページの右側目次を追従させ、長い項目だけを内部スクロールさせる。 */
.article-toc {
position: sticky;
top: 10.8rem;
max-height: calc(100vh - 12.8rem);
padding: 2.2rem;
overflow: hidden;
}
.article-toc nav {
max-height: calc(100vh - 22rem);
overflow-y: auto;
overscroll-behavior: contain;
}
.article-toc__link.is-current {
color: #0874c9;
background: #edf6ff;
}overscroll-behaviorで連続スクロールを抑える
overscroll-behavior: containは、目次の端までスクロールしたときに、同じ操作がすぐページ全体へ伝わるのを抑えます。マウスホイールやタッチ操作で、目次を読んでいる途中に本文が大きく動く感覚を減らせます。
JavaScriptで目次を生成し現在地を示す
目次は記事本文のh2とh3から生成できます。本文上部とサイドバーに同じdata-article-tocを用意しておけば、1回の処理で両方へ同じ項目を出力できます。
h2とh3からリンクを作る
見出しIDはリンク先になるため、重複させないことが重要です。記事側でttl-1、ttl-1-1のように付けておくと、目次と本文の対応を確認しやすくなります。
is-currentとaria-currentを同期する
現在読んでいる見出しに対応するリンクへis-currentを付けると、背景色や文字色で現在地を示せます。同時にaria-current="location"を付けると、支援技術にも現在位置を伝えられます。
(() => {
'use strict';
// 投稿本文の見出しから目次を作り、スクロール位置に応じて現在地を更新する。
const content = document.querySelector('.entry-content');
const toc = document.querySelector('[data-article-toc]');
const list = toc?.querySelector('[data-article-toc-list]');
if (!content || !toc || !list) return;
const headings = [...content.querySelectorAll('h2, h3')];
headings.forEach((heading) => {
const item = document.createElement('li');
const link = document.createElement('a');
link.href = `#${heading.id}`;
link.textContent = heading.textContent.trim();
link.dataset.tocTarget = heading.id;
link.className = 'article-toc__link';
item.append(link);
list.append(item);
});
toc.hidden = false;
const links = [...list.querySelectorAll('[data-toc-target]')];
let ticking = false;
const updateCurrent = () => {
const offset = 120;
let current = headings[0];
headings.forEach((heading) => {
if (heading.getBoundingClientRect().top <= offset) current = heading;
});
links.forEach((link) => {
const active = link.dataset.tocTarget === current.id;
link.classList.toggle('is-current', active);
active
? link.setAttribute('aria-current', 'location')
: link.removeAttribute('aria-current');
});
ticking = false;
};
window.addEventListener('scroll', () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(updateCurrent);
}, { passive: true });
updateCurrent();
})();requestAnimationFrameで更新回数を抑える
scrollイベントは短時間に何度も発生します。毎回すぐに全見出しを確認するのではなく、requestAnimationFrame()で画面描画のタイミングへまとめると、不要な処理を増やしにくくなります。
ハンバーガーメニューを画面内でスクロールさせる
スマートフォンでは、ヘッダーの下から画面下端までをメニュー領域にします。メニュー項目がその高さを超えた場合だけ、メニュー内にスクロールが発生します。
ハンバーガーアイコンの基本的な開閉処理から確認したい場合は、先にハンバーガーメニューを実装します。【サンプルコード付き】もご覧ください。本記事では、その基本処理へ内部スクロール、背面固定、状態のリセットを加えます。
100dvhからヘッダー高を引く
100dvhは、スマートフォンのアドレスバーなどで変化する表示領域を考慮した単位です。100vhより実際の見えている高さへ合わせやすいため、全画面メニューに向いています。
メニュー内だけoverflowを有効にする
/* スマートフォンの主要メニューを画面内に収め、項目が多い場合だけスクロールさせる。 */
body.menu-open {
overflow: hidden;
}
.site-nav {
position: absolute;
top: 100%;
right: 0;
left: 0;
height: calc(100dvh - var(--site-header-height));
padding: 2.4rem 2rem;
overflow: auto;
background: #fff;
opacity: 0;
visibility: hidden;
transform: translateY(-0.8rem);
transition: 0.2s ease;
}
.site-nav.is-open {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
@media (min-width: 1181px) {
.site-nav {
position: static;
height: auto;
overflow: visible;
opacity: 1;
visibility: visible;
transform: none;
}
}開いている間は背面スクロールを止める
メニュー内を動かしているつもりでも本文が動くと、閉じたときに読んでいた位置が変わります。開いている間だけbodyへmenu-openを付け、背面を固定します。
(() => {
'use strict';
// スマートフォンの主要メニューを開閉し、背面固定と読み上げ状態を同期する。
const button = document.querySelector('.menu-toggle');
const navigation = document.querySelector('.site-nav');
const label = button?.querySelector('.screen-reader-text');
if (!button || !navigation) return;
const closeMenu = () => {
button.setAttribute('aria-expanded', 'false');
navigation.classList.remove('is-open');
document.body.classList.remove('menu-open');
if (label) label.textContent = 'メニューを開く';
};
button.addEventListener('click', () => {
const open = button.getAttribute('aria-expanded') !== 'true';
button.setAttribute('aria-expanded', String(open));
navigation.classList.toggle('is-open', open);
document.body.classList.toggle('menu-open', open);
if (label) label.textContent = open
? 'メニューを閉じる'
: 'メニューを開く';
});
navigation.addEventListener('click', (event) => {
if (event.target.closest('a')) closeMenu();
});
window.addEventListener('resize', () => {
if (window.innerWidth > 1180) closeMenu();
});
})();操作しやすさとアクセシビリティを整える
表示できるだけでなく、キーボードや読み上げ環境でも状態が分かるようにします。見た目のクラスとARIA属性を同じ処理で更新すると、状態のずれを防ぎやすくなります。
aria-expandedと読み上げ文言を更新する
閉じているときはaria-expanded="false"と「メニューを開く」、開いているときはtrueと「メニューを閉じる」にします。3本線と×印だけに頼らず、ボタンの目的を文字でも伝えることが大切です。
リンク選択とPC幅への変更で閉じる
メニュー内リンクを選んだ後もメニューが残ると、移動先の内容が見えません。また、開いたまま画面幅をPCサイズへ広げるとbody.menu-openだけが残る場合があります。リンク選択時とブレイクポイント通過時に共通のcloseMenu()を呼び出します。
うまくスクロールしないときの確認方法
スクロールが発生しない場合は、JavaScriptを増やす前にCSSの高さと親要素の状態を確認します。内部スクロールの問題は、対象要素ではなく祖先要素の指定が原因になることもあります。
stickyが効かない場合
stickyの祖先に意図しないoverflow: hiddenやoverflow: autoがあると、その祖先が基準になります。サイドバーの親要素まで開発者ツールで確認し、どの要素がスクロールコンテナになっているかを調べてください。
overflowを付けてもスクロールしない場合
対象要素にheight、max-height、block-sizeのいずれもない場合は、内容に合わせて伸び続けます。開発者ツールでscrollHeightがclientHeightを超えているかを確認すると、スクロール可能な状態か判断できます。
スマホで本文まで動く場合
メニューを開いたときにbody.menu-openが付いているか、閉じたときに外れているかを確認します。iOSや長いページで挙動が安定しない場合は、開いた瞬間のスクロール位置を保存してbodyを固定する方法もありますが、まずは今回の最小構成で必要性を判断してください。
まとめ
- 追従目次は、外枠の
stickyと内側ナビゲーションのoverflow-y: autoを分けて指定する - 内部スクロールには、
max-heightなど表示できる高さの上限が必要 - ハンバーガーメニューは
100dvhからヘッダー高を引き、メニュー内だけをスクロールさせる - メニュー表示中は本文を固定し、リンク選択時とPC幅へ戻ったときに状態を解除する
- 現在地の
is-currentとaria-current、開閉のaria-expandedをJavaScriptで同期する

MEMBER COMMENTS
コメント(0件)