6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

この記事の概要

以下の gif のような目次を作ります。

  • 記事をスクロールしても画面の特定の位置に固定される
  • 目次のうち、今読んでいる見出しをハイライトする
  • ハイライトがスムーズにアニメーションする

準備

HTML はこのような構造になっています。

<body>
    <header>...</header>
    <div class="contents">
      <main class="main">
        <h1 class="main-title">Animated table of contents</h1>
        <div class="main-body">
          <h2 id="foo">Aliquam malesuada bibendum arcu vitae</h2>
          ...
          <h2 id="bar">Cursus turpis massa tincidunt dui ut ornare lectus sit</h2>
          ...
          <h2 id="baz">Morbi non arcu risus quis varius quam quisque id diam</h2>
          ...
          <h2 id="qux">Nunc id cursus metus aliquam eleifend mi</h2>
          ...
          <h2 id="quux">Sed blandit libero volutpat sed cras</h2>
          ...
        </div>
      </main>
      <aside class="aside">
        <h2>Table of contents</h2>
        <ul class="table_of_contents">
          <li class="table_of_contents-item">
            <a href="#foo">Aliquam malesuada bibendum arcu vitae</a>
          </li>
          <li class="table_of_contents-item">
            <a href="#bar">Cursus turpis massa tincidunt dui ut ornare lectus sit</a>
          </li>
          <li class="table_of_contents-item">
            <a href="#baz">Morbi non arcu risus quis varius quam quisque id diam</a>
          </li>
          <li class="table_of_contents-item">
            <a href="#qux">Nunc id cursus metus aliquam eleifend mi</a>
          </li>
          <li class="table_of_contents-item">
            <a href="#quux">Sed blandit libero volutpat sed cras</a>
          </li>
        </ul>
      </aside>
    </div>
    <footer>...</footer>
  </body>

今回は記事の内容的に手動で見出しに id を付与し、見出しと目次のテキストを揃えています。
実務では各種プラグインで自動生成することになると思います。

目次の位置を固定する

まずはじめに目次の位置を固定します。

  .aside {
    align-self: flex-start;
    display: flex;
    flex-direction: column;
    position: sticky;
    row-gap: 1rem;
    top: 2rem;
  }

  .table_of_contents {
    display: flex;
    flex-direction: column;
    list-style: none;
  }

  .table_of_contents-item {
    a {
      border-radius: 0.25rem;
      color: #666;
      display: block;
      padding: 1rem;
      @media (any-hover: hover) {
        &:hover {
          color: inherit;
        }
      }
    }
  }

これはposition: sticky;と位置の指定さえあれば OK です。
今回はtop: 2rem;の位置で固定するようにしました。

スクロール位置にあわせてハイライトする

Intersection Observer API を使い、見出しが画面内にあるかどうかの判定をし、id が同じものにaria-currentをつけます。

  const observeHeadings = document.querySelectorAll(".main-body h2");
  const headingAnchorLinks = document.querySelectorAll(".table_of_contents a");

  const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          headingAnchorLinks.forEach((headingAnchorLink) => {
            if (
              `#${entry.target.id}` === headingAnchorLink.getAttribute("href")
            ) {
              headingAnchorLink.setAttribute("aria-current", "true");
            } else {
              headingAnchorLink.removeAttribute("aria-current");
            }
          });
        }
      });
    },
    {
      rootMargin: "-10% 0px -90%",
    }
  );

  observeHeadings?.forEach((observeHeading) => {
    observer.observe(observeHeading);
  });

rootMargin: "-10% 0px -90%"の書き方によって「画面上部から 10% の位置 〜 画面下部から 90 % の位置に要素があるか」を判定しています。
つまり、範囲指定のようで実際は「設定したラインを見出しが通過したか」の判定をしています。

ちょうど良い位置がどこかは調整する余地があると思いますが、今回はひとまずこの設定で進めます。


次に、CSS を追加します。
[aria-current]属性がtrueな要素に背景色を設定します。

  .aside {
    align-self: flex-start;
    display: flex;
    flex-direction: column;
    position: sticky;
    row-gap: 1rem;
    top: 2rem;
  }

  .table_of_contents {
    display: flex;
    flex-direction: column;
    list-style: none;
  }

  .table_of_contents-item {
    a {
      border-radius: 0.25rem;
      color: #666;
      display: block;
      padding: 1rem;
      @media (any-hover: hover) {
        &:hover {
          color: inherit;
        }
      }
+     &[aria-current="true"] {
+       background-color: #e6e6e6;
+     }
    }
  }

すると、このようになりました。

ハイライト位置の変更をスムーズにアニメーションさせる

CSS anchor positioning API を使い、aria-current="true"のついている要素のサイズ・位置にあわせてハイライトを動かします。

  .aside {
    align-self: flex-start;
    display: flex;
    flex-direction: column;
    position: sticky;
    row-gap: 1rem;
    top: 2rem;
  }

  .table_of_contents {
    display: flex;
    flex-direction: column;
    list-style: none;
+   position: relative;
+   &::after {
+     content: "";
+     position: absolute;
+     inset: anchor(--table-of-contents-link start);
+     z-index: -1;
+     inline-size: anchor-size(--table-of-contents-link inline);
+     block-size: anchor-size(--table-of-contents-link block);
+     border-radius: 0.25rem;
+     background-color: #e6e6e6;
+     transition-duration: 150ms;
+     transition-property: inset, block-size;
+   }
  }

  .table_of_contents-item {
    a {
      border-radius: 0.25rem;
      color: #666;
      display: block;
      padding: 1rem;
      @media (any-hover: hover) {
        &:hover {
          color: #000;
        }
      }
-     &[aria-current="true"] {
-       background-color: #e6e6e6;
-     }
+     &[aria-current="true"] {
+       anchor-name: --table-of-contents-link;
+     }
    }
  }

a要素そのものではなく、親要素の擬似要素に背景色を設定することで、スムーズにアニメーションさせられるようになりました。
これで、冒頭に載せた gif と同じ動きになっています。

おまけ

今のままだと目次をクリックした際に色がつかないので、こんな処理も足しておくと良さそうです。

headingAnchorLinks.forEach((headingAnchorLink) => {
  headingAnchorLink.addEventListener("click", () => {
    headingAnchorLink.setAttribute("aria-current", "true");

    headingAnchorLinks.forEach((otherHeadingAnchorLink) => {
      if (otherHeadingAnchorLink !== headingAnchorLink) {
        otherHeadingAnchorLink.removeAttribute("aria-current");
      }
    });
  });
});

参考

6
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?