デモ
デモを読み込めませんでした。
仕様
- 用途
- ナビゲーション
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- ScrollTrigger
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
記事の横に止めた目次で、いま読んでいる節の項目へ印が滑って移り、その項目の文字が濃くなります。2 節目からの各節に ScrollTrigger を 1 つずつ作り、節の上端が画面の上から 3 分の 1 の線を越えたらその節の項目へ、上へ戻して線より下へ行ったら 1 つ前の節の項目へ、印を移します。
印は目次の中に 1 つだけ置いた要素で、今の項目の位置(offsetTop)と高さへ、0.4 秒の power3.inOut で動かします。文字の色は 0.3 秒かけて切り替えます。線を画面の上 3 分の 1 にするのは、見出しが画面の上のほうへ来て、本文を読み始めるあたりだからです。今の項目のリンクには aria-current を付け、読み上げにも今の節を伝えます。
最後の節が短いと、ページの底まで送っても上端が線に届かず、最後の項目がいつまでも選ばれません。位置の指定を clamp() で囲むと、届かない分がページの底の手前に寄せられ、底まで来たところで選ばれます。境目ごとに「越えたら次へ、戻ったら前へ」と決めているので、一気に飛んでも越えた順に呼ばれ、最後に越えた節が残ります。
目次のリンクで底に近い節へ飛ぶと、ページがそれ以上下がらないため、その下の節の項目が選ばれてしまうことがあります。押した項目は、読む人が自分でスクロールし始めるまで、そのままにしておきます。
線の高さは、ScrollTrigger の start に書いた 33% で変えられます。固定のヘッダーがあるときは、目次を止める高さと、ページ内リンクで飛んだときの余白(scroll-margin-top)に、ヘッダーの高さを足します。ドキュメント、長い記事、LP の目次に向いています。
コード
<div class="doc">
<!-- 目次。今読んでいる節のリンクに aria-current="location" が付き、印(.toc__marker)がそこへ滑る -->
<nav class="toc" aria-labelledby="toc-title">
<p class="toc__title" id="toc-title">このページの内容</p>
<span class="toc__marker" aria-hidden="true"></span>
<ol class="toc__list">
<li><a class="toc__link" href="#overview">概要</a></li>
<li><a class="toc__link" href="#before-you-start">始める前に</a></li>
<li><a class="toc__link" href="#install">CLIのインストール</a></li>
<li><a class="toc__link" href="#configure">プロジェクトの設定</a></li>
<li><a class="toc__link" href="#deploy">デプロイ</a></li>
<li><a class="toc__link" href="#troubleshooting">困ったときは</a></li>
</ol>
</nav>
<article class="doc__article">
<h1 class="doc__title">はじめてのデプロイ</h1>
<p class="doc__lead">ここに説明文が入ります。</p>
<!-- 節の id は、目次のリンクの href と同じにする -->
<section class="doc__section" id="overview">
<h2>概要</h2>
<p>ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。</p>
<p>ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。</p>
</section>
<section class="doc__section" id="before-you-start">
<h2>始める前に</h2>
<p>ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。</p>
<p>ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。</p>
</section>
<section class="doc__section" id="install">
<h2>CLIのインストール</h2>
<p>ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。</p>
<p>ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。</p>
</section>
<section class="doc__section" id="configure">
<h2>プロジェクトの設定</h2>
<p>ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。</p>
<p>ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。</p>
</section>
<section class="doc__section" id="deploy">
<h2>デプロイ</h2>
<p>ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。</p>
<p>ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。</p>
</section>
<section class="doc__section" id="troubleshooting">
<h2>困ったときは</h2>
<p>ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。</p>
<p>ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。</p>
</section>
</article>
</div>
.doc {
--ground: #f2f1ed;
--ink: #262626;
--idle: #6b6b6b; /* 今の節ではない項目 */
--line: rgba(38, 38, 38, 0.12); /* 目次の縦の線 */
--mark: #ffffff; /* 印の地 */
--top: 32px; /* 目次を止める高さ。固定のヘッダーがあるなら、その高さを足す */
display: grid;
grid-template-columns: 240px minmax(0, 680px);
gap: 96px;
padding: 96px clamp(24px, 6vw, 96px);
background: var(--ground);
color: var(--ink);
}
/* 目次は画面に止める。sticky も位置の基準になるので、印とリンクの位置を同じ基準で測れる */
.toc {
position: sticky;
top: var(--top);
align-self: start;
}
.toc__title {
margin: 0 0 16px;
color: var(--idle);
font-size: 12px;
font-weight: 700;
line-height: 1;
letter-spacing: 0.06em;
}
.toc__list {
margin: 0;
padding: 0;
list-style: none;
box-shadow: inset 2px 0 0 var(--line);
}
/* position: relative で、印より手前に描く */
.toc__link {
position: relative;
display: block;
padding: 8px 16px 8px 24px;
color: var(--idle);
font-size: 15px;
font-weight: 600;
line-height: 24px;
letter-spacing: 0.04em;
white-space: nowrap;
text-decoration: none;
transition: color 0.3s;
}
.toc__link[aria-current] { color: var(--ink); }
.toc__link:focus-visible { outline: 2px solid var(--ink); outline-offset: -2px; }
/* 印。白い地と、縦の線に重ねる文字の色の線。y と height は、JS が今の項目に合わせる */
.toc__marker { position: absolute; left: 0; right: 0; top: 0; height: 0; }
.toc__marker::before { content: ""; position: absolute; inset: 0 0 0 8px; border-radius: 4px; background: var(--mark); }
.toc__marker::after { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 2px; background: var(--ink); }
.doc__title { margin: 0 0 16px; font-size: clamp(32px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.doc__lead,
.doc__section p { margin: 0 0 16px; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }
.doc__lead { margin-bottom: 64px; }
/* 節と節のあいだに隙間を作らない(下の余白は節の中に持つ)。ページ内リンクで飛ぶと、見出しが目次と同じ高さに来る */
.doc__section { padding-bottom: 48px; scroll-margin-top: var(--top); }
.doc__section h2 { margin: 0 0 16px; font-size: 24px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
/* 列が並ばない幅では、目次を記事の上に置く(止めない) */
@media (max-width: 900px) {
.doc { grid-template-columns: minmax(0, 1fr); gap: 48px; }
.toc { position: relative; top: auto; }
}
@media (prefers-reduced-motion: reduce) {
.toc__link { transition: none; }
}
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const LINE = "33%"; // 節の上端が、画面の上からこの高さを越えたら、その節を今の節にする
document.querySelectorAll(".toc").forEach((toc) => {
const links = [...toc.querySelectorAll(".toc__link")];
const marker = toc.querySelector(".toc__marker");
let current = -1;
// 印を今の項目へ。offsetTop と offsetHeight は、目次の中での位置と高さ
const place = (animate) => {
const link = links[current];
const to = { y: link.offsetTop, height: link.offsetHeight, overwrite: true };
if (animate && !reduceMotion) gsap.to(marker, { ...to, duration: 0.4, ease: "power3.inOut" });
else gsap.set(marker, to);
};
const setActive = (i) => {
if (i === current) return;
links[current]?.removeAttribute("aria-current");
links[i].setAttribute("aria-current", "location"); // 読み上げにも、今の節を伝える
const first = current < 0;
current = i;
place(!first);
};
// まだどの節も線を越えていなければ、最初の項目
setActive(0);
// 目次で押した項目は、飛んだ先がページの底でも、自分でスクロールするまでその項目のままにする
let chosen = false;
const follow = (i) => chosen || setActive(i);
links.forEach((link, i) => link.addEventListener("click", () => {
chosen = true;
setActive(i);
}));
for (const type of ["wheel", "touchstart", "keydown", "pointerdown"]) {
window.addEventListener(type, () => (chosen = false), { capture: true, passive: true });
}
// 2 節目からの各節の上端が線を越えたらその節へ、上へ戻して線より下へ行ったら 1 つ前の節へ。
// 一気に飛んでも、越えた順に呼ばれるので、最後に越えた節が残る
links.forEach((link, i) => {
const section = document.querySelector(link.hash);
if (i === 0 || !section) return;
ScrollTrigger.create({
trigger: section,
// 最後の節が短くて線まで上がりきらないときも、ページの底の手前で選ばれるように clamp() で囲む
start: `clamp(top ${LINE})`,
onEnter: () => follow(i),
onLeaveBack: () => follow(i - 1),
});
});
// 書体の読み込みや幅の変化で項目の位置が変わったら、印を置き直す
new ResizeObserver(() => place(false)).observe(toc);
});