本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
ナビゲーション

読んでいる節に合わせて目次の現在地の印が滑って移る

デモ

操作:スクロール。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。 触っている間は、ホイールでデモの中がスクロールします。

仕様

動作
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 の目次に向いています。

コード

HTML
<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>

CSS
.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; }
}

JavaScript
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);
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。