当サイトは、アフィリエイト広告を利用しています

【React】Gatsby製ブログで目次をハイライトする方法

作成日:2022月06月17日
更新日:2023年01月18日

このブログ(Gatsby)にZennブログみたいに記事の画面をスクロールすると目次が合わせてハイライトされる
ようにしたいと思いIntersection Observer APIを使って実装してみたので忘備録として残す。
Codesandboxを使って基本的な動作を確認する

実装方法

目次をハイライトするにはWebAPIのintersection Observer API(交差オブザーバー API)
react用のパッケージのreact-intersection-observerを使うのが良さそう。 今回はintersection Observer APIを使っていく。

intersection-observer APIとは?

監視対象(指定したHTMLの要素)が viewport 、または、特定の要素と交差した(要素と要素が重なった)場合
に実行するコールバック関数を登録できるAPI
ブラウザによってはサポートされていないものがある(IEなど)
詳細は交差オブザーバー API参照
まとめると監視領域と監視対象を決めて、スクロールなどで監視領域に監視対象が入る(交差する)タイミングで仕込んだ
コールバック関数を発火できる。

基本的な使い方

基本的にはIntersectionObserverに引数として

  • コールバック関数
  • オプション

を渡してIntersectionObserverオブジェクトを作成し、 IntersectionObserverオブジェクトのobserveメソッドを使って
監視するエレメントを登録する。流れは下記のような形になる。

Sample
~
//交差時に動くコールバックを定義
const callback = (entries) => {
console.log(entries);
};
//オプションを定義
const options = {
root: null,
rootMargin: "0px",
threshold: 0.5
};
//IntersectionObserverオブジェクトの作成
const observer = new IntersectionObserver(callback, options);
// // domから監視対象を取得
let targets = Array.from(document.querySelectorAll(".target"));
// 監視対象をオブジェクトにセットする
targets.forEach((terget) => observer.observe(terget, options));
}, []);
~

オプションについて

IntersectionObserverオブジェクト作成時に設定できるオプションは
三つある。

root

監視対象の要素が交差したかどうかを判定するための要素を指定します。
nullを指定した場合(デフォルト)は viewportが使用される。
監視範囲のこと。viewportは表示されている画面全体

rootMargin

rootの監視範囲を拡張、または縮小する設定。 交差の判定に使われる rootのマージンを指定できる。正の値を指定するとrootの監視範囲が広がり
監視対象要素が見えるよりも早くコールバック関数が実行される。負の値を指定するとrootの監視範囲が
監視対象要素が見え始めてから少し遅れてコールバックが実行される。

threshold

どのくらい監視対象要素が監視範囲に入った(交差した)らコールバックを実行するかを 0 〜 1 の間で指定する

サンプル実装でintersection-observer APIの仕組みと動作を確認する。

目次ハイライトをする前にサンプル実装して動作を確認する

DOMを直接操作するver

domを直接操作してすることで実現はできている。

js
/** @jsxImportSource @emotion/react */
import { useEffect } from "react";
import { css } from "@emotion/react";
import "./styles.css";
export default function App() {
const array = new Array(1, 2, 3, 4, 5, 6);
useEffect(() => {
// 監視対象がviewPortに入った(交差した)時に実行するコールバック関数
const callback = (entries) => {
// 監視範囲に入った監視対象(IntersectionObserverEntity)の取得
const entry = entries.find((entry) => entry.isIntersecting);
if (entry) {
// 監視対象(IntersectionObserverEntity)から監視対象(DOM要素)
//のindexを取得
const index = targets.indexOf(entry.target);
// 監視対象(DOM要素)をループしてstyleを変更する
targets.forEach((item, i) => {
i === index
? // ? item.classList.add("active")
// : item.classList.remove("active");
(item.style.backgroundColor = "skyBlue")
: (item.style.backgroundColor = "white");
});
}
};
//IntersectionObserverオブジェクトの作成
const observer = new IntersectionObserver(callback, options);
// domから監視対象を取得
let targets = Array.from(document.querySelectorAll("#target"));
// 監視対象をオブジェクトにセットする
targets.forEach((terget) => observer.observe(terget, options));
}, []);
const options = {
// 監視範囲(viewPort)
root: null,
// 監視範囲のマージン
rootMargin: "0px 0px -95% 0px",
// どのくらい見えたらコールバック関数を実行するか(0~1の間)
threshold: 0
};
return (
<div className="App">
{array.map((val) => (
<li
id="target"
key={val}
css={[
css`
height: 500px;
`
]}
>
{val}
</li>
))}
</div>
);
}

コードサンドボックス

下記のCodeSandBoxのサンプルの載せる
※ビューポートを監視エリアにしてるため高さを変えた場合は
全画面表示にしないと動作がおかしくなる

React IntersectionObserver Sample

EmotionとuseStateを使う場合

DOMを直接操作するのではなく、 useStateを使ってレンダリングさせるようにしたversion

js
/** @jsxImportSource @emotion/react */
import { useEffect, useState } from "react";
import { css } from "@emotion/react";
import "./styles.css";
export default function App() {
// 記事リスト
const article = [
{ id: "1", title: "見出し1", contents: "テスト1", color: "block" },
{ id: "2", title: "見出し2", contents: "テスト2", color: "block" },
{ id: "3", title: "見出し3", contents: "テスト3", color: "block" },
{ id: "4", title: "見出し4", contents: "テスト4", color: "block" },
{ id: "5", title: "見出し5", contents: "テスト5", color: "block" }
];
// 記事リストをuseStateに設定する
// スタイル変更時に再レンダリングさせるため
const [articles, setArticle] = useState(article);
useEffect(() => {
// 監視対象がviewPortに入った時のコールバック関数
// 引数としてIntersectionObserverEntityを受け取る
const callback = (entries) => {
// 監視領域に入った監視対象(IntersectionObserverEntity)を取得
const entry = entries.find((entry) => entry.isIntersecting);
// ある場合
if (entry) {
// useStateの記事を更新する
setArticle(
// 記事リストから監視領域に入ったDOMのidと一致してる記事を特定し
// 更新する。
articles.map((prev) => {
if (prev.id === entry.target.id) {
return { ...prev, color: "skyBlue" };
} else {
return { ...prev, color: "white" };
}
})
);
}
};
//IntersectionObserverオブジェクトの作成
const observer = new IntersectionObserver(callback, options);
// // domから監視対象を取得
let targets = Array.from(document.querySelectorAll(".target"));
// 監視対象をオブジェクトにセットする
targets.forEach((terget) => observer.observe(terget, options));
}, []);
const options = {
root: null,
rootMargin: "0px 0px -95% 0px",
threshold: 0
};
// let observer = new IntersectionObserver(callback, options);
return (
<div className="App">
<div>
{articles.map((contents) => (
<ArticleCom article={contents} />
))}
</div>
</div>
);
}
// 記事コンポーネント
const ArticleCom = ({ article }) => {
return (
<div key={article.id}>
{/* idを記事オブジェクトから設定する(監視対象特定のため) */}
{/* 監視対象に入れるためクラスを付与 */}
{/* 記事オブジェクトのcolorで色を決めるようにする */}
<div id={article.id} className="target" css={[style(article.color)]}>
{article.title}
</div>
</div>
);
};
const style = (color) => [
css`
border: 1px solid black;
background-color: ${color};
height: 500px;
`
];

コードサンドボックス

下記のCodeSandBoxのサンプルの載せる
※ビューポートを監視エリアにしてるため高さを変えた場合は
全画面表示にしないと動作がおかしくなる

React IntersectionObserver Hooks Sample

ソースコードの解説

EmotionとuseStateを使う場合のソースコードを解説する

useStateを使う

監視対象となるDOM要素の元となるオブジェクトをhooksのuseStateにセットする。
理由は後述するが、こうしないとIntersectionObserverで交差を検知しコールバックを
実行しても要素のスタイルを書き換えることができないため。

App.jsx
~
// 記事リスト
const article = [
{ id: "1", title: "見出し1", contents: "テスト1", color: "block" },
{ id: "2", title: "見出し2", contents: "テスト2", color: "block" },
{ id: "3", title: "見出し3", contents: "テスト3", color: "block" },
{ id: "4", title: "見出し4", contents: "テスト4", color: "block" },
{ id: "5", title: "見出し5", contents: "テスト5", color: "block" }
];
// 記事リストをuseStateに設定する
// スタイル変更時に再レンダリングさせるため
const [articles, setArticle] = useState(article);
~

useEffect内でIntersectionObserverオブジェクトを作成する。

監視対象にはDOM要素を設定する必要があるため、useEffectで一度画面描画された後に
IntersectionObserverオブジェクトを作成する。
またuseEffect内のコールバック関数でuseStateに設定した記事リストを更新することで
さらに更新結果を再レンダーさせるようにする必要がある。

App.jsx
~
useEffect(() => {
// 監視対象がviewPortに入った時のコールバック関数
// 引数としてIntersectionObserverEntityを受け取る
const callback = (entries) => {
// 監視領域に入った監視対象(IntersectionObserverEntity)を取得
const entry = entries.find((entry) => entry.isIntersecting);
// ある場合
if (entry) {
// useStateの記事を更新する
setArticle(
// 記事リストから監視領域に入ったDOMのidと一致してる記事を特定し
// 更新する。
articles.map((prev) => {
if (prev.id === entry.target.id) {
return { ...prev, color: "red" };
} else {
return { ...prev, color: "black" };
}
})
);
}
};
//IntersectionObserverオブジェクトの作成
const observer = new IntersectionObserver(callback, options);
// // domから監視対象を取得
let targets = Array.from(document.querySelectorAll(".target"));
// 監視対象をオブジェクトにセットする
targets.forEach((terget) => observer.observe(terget, options));
}, []);
const options = {
root: null,
rootMargin: "0px",
threshold: 1
};
~

useStateに記事リストを設定した理由は、IntersectionObserverに登録したコールバック関数の
結果を受けて画面を再レンダーさせる必要があるため。

Reactではコンポーネントが再レンダーはされるのは下記のパターンの時のため 記事リストをstateに設定した。

  • stateが更新された時
  • propsが更新された時
  • 親コンポーネントが再レンダリングされた時

参考

下記の記事を参考にさせて頂きました。

Reactでの記事の目次ハイライトサンプル

上記のサンプルで動作が確認できたので、実際にIntersectionObserverを使って
記事の目次をスクロールに合わせてハイライトするサンプルを作ってみる。

App.jsx
/** @jsxImportSource @emotion/react */
import { css } from "@emotion/react";
import { useState, useEffect } from "react";
export default function App() {
// 記事リスト
const articleList = [
{ id: "1", title: "見出し1", contents: "テスト1", color: "block" },
{ id: "2", title: "見出し2", contents: "テスト2", color: "block" },
{ id: "3", title: "見出し3", contents: "テスト3", color: "block" },
{ id: "4", title: "見出し4", contents: "テスト4", color: "block" },
{ id: "5", title: "見出し5", contents: "テスト5", color: "block" }
];
const [articles, setArticle] = useState(articleList);
useEffect(() => {
// 監視対象がviewPortに入った時のコールバック関数
// 引数としてIntersectionObserverEntityを受け取る
const callback = (entries) => {
// 監視領域に入った監視対象(IntersectionObserverEntity)を取得
const entry = entries.find((entry) => entry.isIntersecting);
// ある場合
if (entry) {
// useStateの記事を更新する
setArticle(
// 記事リストから監視領域に入ったDOMのidと一致してる記事を特定し
// 更新する。
articles.map((prev) => {
if (prev.id === entry.target.id) {
return { ...prev, color: "red" };
} else {
return { ...prev, color: "black" };
}
})
);
console.log(articles);
}
};
//IntersectionObserverオブジェクトの作成
const observer = new IntersectionObserver(callback, options);
// // domから監視対象を取得
let targets = Array.from(document.querySelectorAll(".target"));
// 監視対象をオブジェクトにセットする
targets.forEach((terget) => observer.observe(terget, options));
return () => observer.disconnect();
}, []);
// オプション
const options = {
root: null,
// 下から-95%にして監視範囲を上部5%だけにする
rootMargin: "0px 0px -95% 0px",
// 0にする
threshold: 0
};
return (
<div css={[container]}>
{/* 記事 */}
<div css={[articleArea]}>
{articles.map(({ id, title, color }) => (
<div css={[article]} id={id} className="target">
<div css={[border]}>{id}</div>
<div css={[border]}>{title}</div>
</div>
))}
</div>
{/* 目次 */}
<div css={[indexArea]}>
{articles.map(({ title, color }) => (
<div css={[index]}>
<div css={[style(color)]}>{title}</div>
</div>
))}
</div>
</div>
);
}
// 全体のスタイル
const container = () => [
css`
display: grid;
grid-template-columns: minmax(500px, 4fr) minmax(200px, 1fr);
column-gap: 50px;
align-items: start;
`
];
// 目次エリアのスタイル
const indexArea = () => [
css`
position: sticky;
top: 10px;
border: 1px solid black;
`
];
// 目次のスタイル
const index = () => [
css`
display: grid;
grid-template-columns: repeat(1, 100px);
text-align: center;
`
];
// 記事エリアのスタイル
const articleArea = () => [
css`
display: grid;
grid-template-columns: repeat(1, minmax(300px, 1fr));
gap: 2px;
`
];
//記事のスタイル
const article = () => [
css`
display: grid;
grid-template-columns: minmax(100px, 2fr) minmax(300px, 5fr);
text-align: center;
&:hover {
/* 上に動かす */
transform: translate(0px, -1px);
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.3);
}
transition-property: all;
transition-duration: 0.3s;
transition-delay: 0s;
transition-timing-function: ease;
height: 500px;
`
];
const border = () => [
css`
border: 1px solid black;
`
];
// 目次の色を変更
const style = (color) => [
css`
color: ${color};
`
];

基本的にやってることはEmotionとuseStateを使う場合と同じですが
IntersectionObserverと交差した時のコールバック関数で色を変えるDOM要素を
目次にしている感じです。

コードサンドボックス

codesandboxで動作確認した ※ビューポートを監視エリアにしてるため高さを変えた場合は
全画面表示にしないと動作がおかしくなる

React IntersectionObserver Index highlight 

新着記事

タグ別一覧
top