追従して、読んでいる箇所がハイライトされて、ハイライトがスムーズにアニメーションする、目次

## この記事の概要

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

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214677/9928323c-c383-a58b-6551-d7a124da1ffe.gif)

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

## 準備

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

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

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

## 目次の位置を固定する

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

```diff_css
  .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;`の位置で固定するようにしました。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214677/2cfcada8-9f20-9288-ce3d-b73e008a7fdb.gif)

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

[Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) を使い、見出しが画面内にあるかどうかの判定をし、id が同じものに`aria-current`をつけます。

```javascript
  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`な要素に背景色を設定します。

```diff_css
  .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;
+     }
    }
  }
```

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

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214677/d96009a0-888c-3ca2-b9b5-4c6ac62f2bf6.gif)

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

[CSS anchor positioning API](https://developer.chrome.com/blog/anchor-positioning-api) を使い、`aria-current="true"`のついている要素のサイズ・位置にあわせてハイライトを動かします。

```diff_css
  .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 と同じ動きになっています。


![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214677/9928323c-c383-a58b-6551-d7a124da1ffe.gif)

## おまけ

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

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

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

## 参考

https://qiita.com/iwmy/items/7e557c7777c927d1bfaa

https://qiita.com/degudegu2510/items/25a3efb3882692227b1a

https://azukiazusa.dev/blog/css-anchor-positioning/

https://x.com/tak_dcxi/status/1799960922292629544