デモ
デモを読み込めませんでした。
仕様
- 用途
- ナビゲーション
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- ScrollTrigger
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
横に固定した目次の中で、印が今読んでいる節の項目へ滑って移り、その項目の文字が濃くなります。印は目次の中にひとつだけ置いた要素で、今の項目の offsetTop と高さに合わせて、0.4秒の power3.inOut で動きます。今の項目のリンクには aria-current も付けています。
切り替わるのは、節の上端が画面の上から3分の1の線を越えたときです。見出しが画面の上のほうへ来て、本文を読み始めるのがだいたいこのあたりなので、ここを境目にしました。2節目からの各節にそれぞれ ScrollTrigger を作り、線を越えたらその節へ、戻って線より下へ行ったらひとつ前の節へ印を移します。線の高さは start に書いた33%で、値を小さくするほど線が上へ寄り、切り替わりが遅くなります。
境目ごとに「越えたら次、戻ったら前」と決めているので、一気に遠くまでスクロールしても越えた順に処理が呼ばれ、最後に越えた節が残ります。見落としやすいのは最後の節が短いときで、ページの一番下までスクロールしても上端が線まで上がらず、最後の項目がいつまでも選ばれません。開始位置の指定を clamp() で囲むと、届かない分がページの一番下より手前に寄せられ、一番下まで来たところで選ばれます。
目次のリンクでページの下のほうの節へ飛んだときにも、ずれが起きます。ページがそれ以上下へ動けないので、押した節ではなく、その下の節が選ばれてしまうことがあります。押した項目は、読む人が自分でスクロールし始めるまで選んだままにしました。固定のヘッダーがあるページでは、目次を固定する高さと scroll-margin-top の両方に、ヘッダーの高さを足します。
コード
<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);
});