この記事の概要
以下の 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");
}
});
});
});
参考