CATEGORIES:CSS JAVA WORDPRESS
スクロールに付いてくる目次を右サイドに表示

はじめに

ベースの設定

HTML

CSS

調整する場合

おわりに

本文の見出しから目次を自動で作り、右サイドに表示する。スクロールすると目次が付いてきて、今読んでいる所が光る。付いてくる位置やスクロールの速さは数字の変更だけで調整できる。

動きは次のとおり。

  • 本文の見出し(h2・h3)から、目次を自動で作る
  • スクロールしても、目次が画面の上に付いてくる
  • 今読んでいる見出しの項目が濃くなる
  • 項目を押すと、その見出しまでスーッとスクロールする
  • 見出しが1つ以下の記事は「NO CONTENTS」とだけ出る

※ 本文と右サイドが横に並んだ2カラムのページがある前提。jQuery は不要(読み込んであれば jQuery でスクロールする)。
※Font Awesome はアイコンだけに使っているだけ。

目次の配置先を用意する

右サイドの、目次を出したい位置に配置。中身は空のままでいい。JavaScript が本文の見出しから作る。
※ 最初は hidden で隠しておく。JavaScript が動かない環境では、空の目次が出ないように。

<!-- 目次:中身は JavaScript が本文の見出しから作る。最初は hidden で隠しておく -->
<nav class="toc" id="toc" aria-label="目次" hidden>
	<p class="toc_title"><i class="fa-solid fa-bookmark" aria-hidden="true"></i> <span class="toc_label">CONTENTS</span></p>
	<ol class="toc_list"></ol>
</nav>

Font Awesomeをアイコンで利用する場合は <head> 内で読み込む。すでに読み込んでいれば不要。

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">

目次を作る

下記を </body> の直前に配置。本文を囲む要素のクラス名だけ、自分のサイトに合わせて CONTENT を変える。たとえば本文が <div class="entry-content"> の場合は、'.entry-content' にする。

<script>
/* ===== JavaScript(目次) =====
 * ・本文の見出し(h2・h3)を拾って、目次のリストを作る
 * ・見出しに id が無ければ付ける(目次のリンク先)
 * ・スクロールに合わせて、今読んでいる見出しの項目に is-current を付ける
 * ・目次をクリックすると、その見出しまでスーッとスクロールする(jQuery があれば jQuery で)
 * ・見出しが2つ未満なら、「NO CONTENTS」とだけ表示する
 */
( function () {
	'use strict';

	var CONTENT  = '.content';      // ★ 本文を囲む要素
	var HEADINGS = 'h2, h3';        // ★ 目次に拾う見出し
	var OFFSET   = 20;              // ★ 見出しへ飛んだ時、画面の上に空ける距離(px)
	var LINE     = 100;             // ★ 画面の上から何pxを過ぎた見出しを「今」とするか
	var SPEED    = 600;             // ★ スクロールの時間(ミリ秒)
	var EMPTY    = 'NO CONTENTS';   // ★ 見出しが2つ未満の時の表示('' にすると目次ごと隠す)

	var toc = document.getElementById( 'toc' );
	var content = document.querySelector( CONTENT );
	if ( ! toc || ! content ) {
		return;
	}

	var list = toc.querySelector( '.toc_list' );
	var headings = Array.prototype.slice.call( content.querySelectorAll( HEADINGS ) )
		.filter( function ( h ) { return h.textContent.trim() !== ''; } );

	// 見出しが2つ未満:「NO CONTENTS」だけ表示(EMPTY が空なら何も出さない)
	if ( headings.length < 2 ) {
		if ( EMPTY ) {
			toc.querySelector( '.toc_label' ).textContent = EMPTY;
			list.remove();
			toc.hidden = false;
		}
		return;
	}

	// 目次を作る
	var links = headings.map( function ( h, i ) {
		if ( ! h.id ) {
			h.id = 'toc-' + ( i + 1 );
		}
		var li = document.createElement( 'li' );
		li.className = 'toc_' + h.tagName.toLowerCase(); // h3 は字下げ用のクラス
		var a = document.createElement( 'a' );
		a.href = '#' + h.id;
		a.textContent = h.textContent.trim();            // 文字だけ使う(HTML は入れない)
		li.appendChild( a );
		list.appendChild( li );
		return a;
	} );
	toc.hidden = false;

	// 目次から飛んだ見出しの番号。自分でスクロールし直すまでは、これを「今」にする
	var clicked = -1;
	[ 'wheel', 'touchstart', 'keydown' ].forEach( function ( ev ) {
		window.addEventListener( ev, function () { clicked = -1; }, { passive: true } );
	} );

	// 今読んでいる見出しを光らせる
	function update() {
		var current = 0;
		headings.forEach( function ( h, i ) {
			if ( h.getBoundingClientRect().top <= LINE ) {
				current = i;
			}
		} );
		// 一番下まで来たら最後の見出し(最後の章が短くても光るように)
		if ( window.innerHeight + window.pageYOffset >= document.documentElement.scrollHeight - 2 ) {
			current = headings.length - 1;
		}
		if ( clicked >= 0 ) {
			current = clicked;
		}
		links.forEach( function ( a, i ) {
			a.classList.toggle( 'is-current', i === current );
		} );
	}

	// クリックでスクロール
	list.addEventListener( 'click', function ( e ) {
		var a = e.target.closest( 'a' );
		if ( ! a ) {
			return;
		}
		var target = document.getElementById( a.getAttribute( 'href' ).slice( 1 ) );
		if ( ! target ) {
			return;
		}
		e.preventDefault();
		clicked = links.indexOf( a );
		update();

		var top = target.getBoundingClientRect().top + window.pageYOffset - OFFSET;
		var reduce = window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches; // 動きを減らす設定の人には一瞬で移動

		if ( window.jQuery && ! reduce ) {
			window.jQuery( 'html, body' ).stop().animate( { scrollTop: top }, SPEED );
		} else {
			window.scrollTo( { top: top, behavior: reduce ? 'auto' : 'smooth' } );
		}
		try {
			window.history.replaceState( null, '', '#' + target.id ); // URL の # も変える(履歴は増やさない)
		} catch ( err ) {
			// プレビュー画面(iframe)など、URL を書き換えられない所では何もしない(スクロールは動く)
		}
	} );

	var ticking = false;
	window.addEventListener( 'scroll', function () {
		if ( ! ticking ) {
			window.requestAnimationFrame( function () { update(); ticking = false; } );
			ticking = true;
		}
	}, { passive: true } );
	window.addEventListener( 'load', update );
	window.addEventListener( 'resize', update );
	window.addEventListener( 'pageshow', update );
	update();
}() );
</script>

※ 目次の居場所(HTML)より後ろに置くこと。先に読み込むと、居場所が見つからずに何も起きない。

デザインと追従の設定(CSS)

下記を CSS に配置。色は CSS変数(--toc-)の数字を変えるだけ。

/* ===== ここから CSS(目次) ===== */
.toc {
	--toc-top: 20px;         /* ★ スクロールした時、画面の上から何pxの位置に付いてくるか */
	--toc-color: #222222;    /* ★ 見出し(CONTENTS)と、今読んでいる項目の色 */
	--toc-sub: #888888;      /* ★ その他の項目の色 */
	--toc-line: #dddddd;     /* ★ 左の線の色 */
	margin-top: 30px;
}
.toc[hidden] {
	display: none;           /* リセットCSSの nav { display: block } に負けないように */
}
.toc_title {
	margin: 0 0 8px;
	font-weight: bold;
	color: var(--toc-color);
}
.toc_list {
	list-style: none;
	margin: 0;
	padding: 0;
	border-left: 1px solid var(--toc-line);
}
.toc_list a {
	display: block;
	padding: 4px 0 4px 12px;
	margin-left: -1px;       /* 左の線と重ねる */
	border-left: 2px solid transparent;
	font-size: 0.95em;
	line-height: 1.5;
	color: var(--toc-sub);
	text-decoration: none;
	transition: color 0.2s, border-color 0.2s;
}
.toc_h3 a {
	padding-left: 24px;      /* h3 は一段下げる */
	font-size: 0.9em;
}
.toc_list a:hover,
.toc_list a.is-current {     /* 今読んでいる項目 */
	color: var(--toc-color);
}
.toc_list a.is-current {
	border-left-color: var(--toc-color);
}
/* スクロールに付いてくる */
.toc {
	position: sticky;
	top: var(--toc-top);
	max-height: calc(100vh - var(--toc-top) * 2);
	overflow-y: auto;        /* 目次が長い時は目次の中だけスクロール */
}
/* 見出しへ飛んだ時、画面の端にくっつかないように */
.content h2,
.content h3 {
	scroll-margin-top: var(--toc-top, 20px);
}
/* ===== ここまで CSS(目次) ===== */

※ float で並べている場合は、float: none で解除しておく。flex と float は一緒に効かない。
※ リセットCSSに nav { display: block } があると、hidden が効かずに空の目次が見えてしまう。目次のCSSの .toc[hidden] は、そのための指定。

WordPressで記事ページだけに出す場合

目次の配置先は、single.php の右サイドの中に置く。サイドバーを sidebar.php で読み込んでいる場合は、get_sidebar() の下に置けば記事ページにだけ出る。

<?php // 目次(右サイドの好きな位置に。中身は toc.js が本文の見出しから作る) ?>
<nav class="toc" id="toc" aria-label="目次" hidden>
	<p class="toc_title"><i class="fa-solid fa-bookmark" aria-hidden="true"></i> <span class="toc_label">CONTENTS</span></p>
	<ol class="toc_list"></ol>
</nav>

JavaScript は、<script> と </script> を外した中身を、テーマの js/toc.js として保存。functions.php に下記を追加して、記事ページだけ読み込む。

/**
 * 記事ページの目次:記事(投稿)ページだけ js/toc.js を読み込む
 * wp_enqueue_scripts フックで、フッター(</body> の直前)に出力する
 */
function mysite_enqueue_toc() {
	if ( ! is_singular( 'post' ) ) {
		return;
	}
	wp_enqueue_script(
		'mysite-toc',
		get_template_directory_uri() . '/js/toc.js',
		array(),
		filemtime( get_template_directory() . '/js/toc.js' ), // ファイルを更新したら自動でキャッシュ更新
		true // フッターで読み込む(本文が表示されてから動かす)
	);
}
add_action( 'wp_enqueue_scripts', 'mysite_enqueue_toc' );

CSS は、テーマのスタイルシート(style.css など)に追加。

※ 親テーマを直接編集せず子テーマを使っている場合は、get_template_directory を get_stylesheet_directory に変える。

調整するときの変更箇所

調整するときの変更箇所は次のとおり。

変えたいもの変更する場所
目次が付いてくる位置(画面の上から)CSS の --toc-top(初期値 20px)
見出しの色・項目の色・左の線の色CSS の --toc-color --toc-sub --toc-line
本文を囲む要素JS の CONTENT
目次に拾う見出しJS の HEADINGS(初期値 'h2, h3')
見出しへ飛んだ時に上に空ける距離JS の OFFSET(CSS の --toc-top とそろえる)
「今」と判定する位置JS の LINE(初期値 100px)
スクロールの速さJS の SPEED(初期値 600ミリ秒)
見出しが無い記事の表示JS の EMPTY('' にすると目次ごと出さない)

※ JavaScript が動かない環境では、目次は出ないだけで、本文はそのまま読める。
※ スマホなど右サイドが本文の下に回るレイアウトでは、目次も本文の下に出る。
※ 表示環境によって動きの見え方が異なる場合があるため、本番に反映する前に確認を推奨。

NOTICES

  • 記事内容は実装させたものがほとんどですが自己責任で参考にしてください。

TO HEADER