🫑tech.miyabitti
Markdownmdで開く
•

Scroll-driven Animationsを使って、JS無しで動く目次を作ってみた話【CSS】

CSS Scroll-driven Animationsを活用して、JavaScriptゼロで本文スクロールに連動する目次を実装した記録。タイムライン共有、Chromiumの描画バグ、スクロールバー非表示による動的マスクの沼まで。

目次

#はじめに

前回の記事で、最近のモダンHTMLとCSSの進化やScroll-driven Animationsの可能性について色々と調べてまとめました。

記事を公開したあと、「これ、ブログの目次のアクティブハイライトもJavaScriptを1行も書かずに作れるのでは?」というアイデアを思いつきました。

実際に試行錯誤しながら作ってみたところ、非対応ブラウザ向けの最低限のフォールバック用スクリプトを除き、Chromium系などの対応ブラウザでは 完全な 0 KB JS で本文のスクロールに追従する目次が完成しました。

ただ、当たり前の話ではありますが「view-timeline をちょろっと指定したら一発で動いた!」なんて甘い話には到底ならず、

  • 本文と目次でDOMツリーが離れている問題
  • 往復スクロールすると色が変わらなくなるバグ
  • 目次自体のスクロールバーを消していたのですが、スクロールできるとユーザーに気づいてもらえない問題

といった地味な沼にハマりました。

本記事では、その試行錯誤の過程と、最終的にどのように解決したのかを開発記録として共有してみたいと思います。

#実装のゴールと全体像

今回目指した要件は以下の3点です。

  • 対応ブラウザでは完全な 0 KB JS: IntersectionObserver や window.addEventListener('scroll') を一切使わず、CSS(Scroll-driven Animations)だけで現在読んでいるセクションを目次上でハイライトする。
  • 項目数が多いときの操作性: 目次のスクロールバーはデザイン上非表示にしつつも、スクロール可能であることが自然に伝わるUIにする。
  • 非対応環境への配慮: Firefox など未対応のブラウザでは、最低限のJSフォールバックで同様のアクティブ表示を提供する。

#本文と目次のタイムライン共有

まず最初にぶち当たったのが、DOMツリーの構造的な制約でした。

#DOMツリーが離れている問題

CSSの Scroll-driven Animations では、特定の要素が画面内に入ってきたことを検知するために view-timeline を使います。

/* 観測対象のセクション */
.article-section {
view-timeline-name: --sec-0;
view-timeline-axis: block;
}
/* 目次側のリンク */
.toc-link {
animation-timeline: --sec-0;
}

しかし、W3Cの仕様上、名前付きタイムライン(Named Timeline)は 定義した要素自身、またはその子孫要素からしか参照できない というスコープの制約があります。

本サイトのマークアップでは、本文(<article>)とサイドバーの目次(<aside>)は親子関係にはなく、完全に別の兄弟ツリーに分かれています。

div.page-layout
├── article (本文: ここに見出しやセクションがある)
│ ├── section (view-timeline-name: --sec-0)
│ └── section (view-timeline-name: --sec-1)
└── aside (目次: ここからタイムラインを参照したい)
└── nav.toc
├── a (animation-timeline: --sec-0 は通常届かない!)
└── a (animation-timeline: --sec-1)

このままでは、目次側のリンクから本文のセクションタイムラインを参照することができません。

#timeline-scope でスコープを引き上げる

この制約を突破するために用意されているのが、CSSの timeline-scope プロパティ です。

本文と目次の共通の親要素(レイアウトのルート要素など)で timeline-scope を宣言すると、子孫要素で定義される名前付きタイムラインのスコープを親要素の階層まで引き上げることができます。

<!-- 共通の親要素でタイムライン名を事前宣言 -->
<div class="article-page-layout" style="timeline-scope: --sec-0, --sec-1, --sec-2;">
<!-- 本文エリア -->
<article>
<section class="article-section" style="view-timeline-name: --sec-0; view-timeline-axis: block;">
<h2 id="sec-0">見出し1</h2>
<p>本文テキスト...</p>
</section>
<section class="article-section" style="view-timeline-name: --sec-1; view-timeline-axis: block;">
<h2 id="sec-1">見出し2</h2>
<p>本文テキスト...</p>
</section>
</article>
<!-- 目次エリア(親経由でタイムラインを参照可能) -->
<aside>
<nav class="toc">
<a href="#sec-0" style="animation-timeline: --sec-0;">見出し1</a>
<a href="#sec-1" style="animation-timeline: --sec-1;">見出し2</a>
</nav>
</aside>
</div>

これで、兄弟ツリーにある目次側からも本文セクションのタイムラインが参照できるようになります。

Note

ちなみに、W3Cの仕様(Scroll-driven Animations Level 1)を覗いてみると、タイムライン名を1つずつカンマ区切りで列挙する代わりに、子孫要素で定義されたすべてのタイムラインのスコープを丸ごと親要素に引き上げる timeline-scope: all; という指定も策定されています。

.article-page-layout {
timeline-scope: all; /* 将来的にこれが使えるようになれば超便利 */
}

もしこれが主要ブラウザで広くサポートされるようになれば、後述するような「記事の見出し件数を数えて --sec-0, --sec-1... と動的に親レイアウトへ渡す」といったテンプレート側の処理すら不要になり、CSS1行で完結するようになります。ただ、現時点ではブラウザの実装がまだ追いついていないため、今は <dashed-ident> で具体的に名前を並べる必要があります。

#なぜ見出しではなくセクションを囲むのか?

ここで1点重要なのが、「なぜ見出し(<h2>)単体ではなく、セクション全体(<section>)をラップしてタイムラインを設定するのか?」という点です。

もし見出し要素単体に view-timeline-name: --sec-0 を付与した場合、「その見出しが画面内を通過している一瞬」しかアニメーションが発火しません。見出しが画面上部へスクロールアウトした瞬間、まだ本文を読んでいる最中なのに目次のハイライトが外れてしまいます。

次の見出しが出現するまでの本文全体を <section> で囲み、そのセクションの通過範囲(cover 0% cover 100%)をタイムラインとして割り当てることで、「その章の本文を読んでいる間中、目次のハイライトを維持する」という自然な挙動が実現できるわけです。

#自作rehypeプラグイン(rehype-sectionize.ts)でビルド時に一発自動生成

静的なHTMLを手書きするだけなら手動で <section> を書けば済みますが、ブログ記事はMarkdownで執筆します。記事を書くたびに見出しを手動でタグで囲み、連番のスタイル属性を振るなんて作業は絶対にやりたくありません。

そこで、AstroのMarkdownビルドパイプラインに挟む自作のrehypeプラグイン(src/plugins/rehype-sectionize.ts)を作成しました。

src/plugins/rehype-sectionize.ts
import type { Element, ElementContent, Root } from "hast";
/**
* HASTノードツリーを走査し、h2/h3ごとに連番インデックスを振った
* <section class="article-section" style="view-timeline-name: --sec-${idx}; ..."> で自動ラップする
*/
function sectionizeNodeList(
nodes: ElementContent[],
getNextIndex: () => number,
): ElementContent[] {
const result: ElementContent[] = [];
let currentSection: Element | null = null;
for (const child of nodes) {
if (
child.type === "element" &&
(child.tagName === "h2" || child.tagName === "h3") &&
child.properties?.id !== "footnote-label"
) {
const idx = getNextIndex();
currentSection = {
type: "element",
tagName: "section",
properties: {
className: ["article-section"],
style: `view-timeline-name: --sec-${idx}; view-timeline-axis: block;`,
},
children: [child],
};
result.push(currentSection);
} else if (
child.type === "element" &&
child.children &&
child.children.some(
(c) =>
c.type === "element" && (c.tagName === "h2" || c.tagName === "h3"),
)
) {
// カスタムコンテナ(:::steps など)内の見出しも再帰的に処理
currentSection = null;
child.children = sectionizeNodeList(child.children, getNextIndex);
result.push(child);
} else if (currentSection) {
currentSection.children.push(child);
} else {
// 最初の見出しより前の導入文など
result.push(child);
}
}
return result;
}
export function rehypeSectionize() {
return (tree: Root) => {
let sectionIndex = 0;
tree.children = sectionizeNodeList(tree.children, () => sectionIndex++);
};
}

このプラグインを通すことで、ビルド時にMarkdown内の h2 / h3 が自動的に連番の view-timeline-name を持った <section> でラップされます。カスタムディレクティブ(:::steps など)の中に見出しがあるケースも考慮して再帰的に走査するようにしているのが地味なこだわりポイントです。

あとは、記事テンプレート(src/pages/article/[slug].astro)側で記事の見出し件数に応じた timeline-scope を生成して親レイアウトに渡し、目次コンポーネント側でも同じインデックスを割り振るだけです。

src/pages/article/[slug].astro
---
// 記事内の見出しからタイムラインスコープ用の文字列を生成
const filteredHeadings = headings.filter(
(h) => h.depth > 1 && h.depth <= 3 && h.slug !== "footnote-label",
);
const timelineScope =
filteredHeadings.length > 0
? filteredHeadings.map((_, i) => `--sec-${i}`).join(", ")
: undefined;
---
<div
class="xl:grid xl:grid-cols-[minmax(0,1fr)_260px] xl:gap-12 items-start"
style={timelineScope ? `timeline-scope: ${timelineScope};` : undefined}
>
<article class="article-paper-sheet">
<Content />
</article>
<aside>
<TableOfContents headings={headings} variant="sticky" />
</aside>
</div>
src/components/markdown/TableOfContents.astro
<!-- 目次リンクに同じインデックスのタイムラインをバインド -->
{filteredHeadings.map((heading, index) => (
<li class="toc-item">
<a
href={`#${heading.slug}`}
class="toc-desktop-link"
style={`animation-timeline: --sec-${index}; --sec-name: --sec-${index};`}
>
{heading.text}
</a>
</li>
))}

これで、記事本文のMarkdownを書くだけで、本文のセクションと目次リンクが完全に自動でバインドされる仕組みができました。

#往復スクロール時の描画バグとGPUコンポジタ最適化

タイムラインの接続に成功し、最初は「これで勝った!」と意気揚々とリンクのアクティブハイライトを実装していました。

#最初素朴に実装してみたところ…

最初は素直に、目次リンク自体の background-color をキーフレームアニメーションで切り替える方法をとっていました。

.toc-desktop-link {
animation: highlight-bg linear both;
animation-timeline: var(--sec-name);
animation-range: cover 0% cover 100%;
}
@keyframes highlight-bg {
0%, 100% {
background-color: transparent;
}
0.0001%, 99.9999% {
background-color: var(--toc-active-bg);
}
}

「これで動くべ!」と思って意気揚々とブラウザで動作確認をしていたのですが、ページをスクロールしていると不可解な現象に遭遇しました。

ページを上から下へスクロールしていくときは、目次のハイライトが順調に切り替わっていきます。しかし、1度通過してハイライトされたセクションに上スクロール等で戻ったりすると、2回目以降ハイライトの背景色が一切変わらなくなってしまったのです。

DevToolsを開いてアニメーションのプロパティを確認してみると、アニメーションの計算値(computed value)自体はスクロールに合わせて更新されているように見えます。なのに、画面上の描画(ピクセル)だけが全く動かず、背景色が透明のまま変わらない……。

マジで意味がわからなさすぎて、AIに丸投げしてしまったのですが、Gemini君がアホすぎて無駄にトークンを溶かす結果に…

#原因の裏取り: Chromiumの既知不具合(Issue 562847604)

気を取り直して、Claudeに丸投げしてみたところ、Chromiumのバグを疑いはじめ、なんとドンピシャの不具合が登録されていました。

Warning

Chromium Bug Issue 562847604: Composited background-color animation on a scroll timeline stops repainting after reaching 100%

Chromiumの内部実装(CompositeBGColorAnimation周り)において、スクロールタイムライン上で background-color のアニメーションを実行した際、アニメーションが終点(0%や100%)に達した後にリペイント処理が停止し、スクロールを往復しても画面上の再ラスタライズ(Paint)がスキップされる という既知の不具合でした。

内部的なアニメーションの進捗は動いているのに、コンポジタ側で再描画フラグの更新判定がバグっており、「この要素は再描画不要」と誤認して画面の更新を止めてしまうのが原因だったのです。

#解決策: 純粋なコンポジットプロパティである opacity への移行

原因がブラウザのラスタライズ処理(Paint)の不具合にあると分かったので、解決方針が見えてきました。

ブラウザのレンダリングパイプラインにおいて、background-color の変更はラスタライズ(Paint)を伴う処理です。一方、opacity や transform はラスタライズをそもそも必要とせず、GPUとコンポジタスレッド(Compositor Thread)だけで完結して描画できる「純粋なコンポジットプロパティ(Compositor-only property)」 です。

つまり、background-color 自体を動かすのをやめ、背景用のレイヤーを分離して opacity だけでフェードさせれば、Chromiumのリペイント停止バグを回避できるはずです。

そこで、リンク本体に直接背景色を持たせるのをやめ、ハイライト背景を ::before 疑似要素として切り離しました。

src/styles/articledetail.css
.toc-desktop-link {
position: relative;
z-index: 1;
}
/* ハイライト用の背景を疑似要素に分離 */
.toc-desktop-link::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background-color: var(--toc-active-bg);
opacity: 0;
z-index: -1;
pointer-events: none;
/* コンポジタスレッドでの合成を明示(GPUレイヤー昇格) */
will-change: opacity;
/* スクロールタイムラインで opacity だけを 0 ⇄ 1 に遷移 */
animation: highlight-link-bg linear both;
animation-timeline: var(--sec-name);
animation-range: cover 0% cover 100%;
}
@keyframes highlight-link-bg {
0%, 100% {
opacity: 0;
}
0.0001%, 99.9999% {
opacity: 1;
}
}

このように ::before 疑似要素に will-change: opacity; を付与し、透明度(0 ⇄ 1)の切り替えだけに処理を変更したところ、あれだけ悩まされていた往復スクロールでの描画停止が綺麗に解消しました。

何度上下に激しく往復スクロールしても、綺麗にハイライトが追従してくれます。さらに、ラスタライズを挟まない純粋なコンポジタ処理になったことで、メインスレッドの処理負荷にも邪魔されず、極めて軽快に動くという嬉しいメリットも得られました。

#スクロールバー非表示と「動的マスク」の沼

ハイライトの描画が安定したところで、次の課題となったのが目次エリア自体のスクロール操作性(アフォーダンス) でした。

#スクロールバーを消したら操作性が失われた

記事が長くなって見出しの数が増えると、目次が画面の高さを超えてしまいます。そこで、目次コンテナに最大の高さを設けて縦スクロールできるようにしました。

.toc-scroll-area {
max-height: calc(100vh - 8rem);
overflow-y: auto;
}

しかし、目次枠の中にブラウザ標準のスクロールバーが表示されていると、細いサイドバーの中でデザイン的にどうしても野暮ったく見えてしまいます。

そこで、よくあるCSSでスクロールバーを非表示にしました。

.toc-scroll-area {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE / 旧Edge */
}
.toc-scroll-area::-webkit-scrollbar {
display: none; /* Chrome / Safari */
}

ところが、スクロールバーを完全に消してしまうと、今度は「この目次は下にまだ続きがあって、スクロールできる」という視覚的な手がかり(アフォーダンス)が完全に失われてしまうという問題が発生しました。パッと見では下に見出しが隠れていることに誰も気づけません。

#静的マスクの導入と、2つの落とし穴

そこでよくあるUIデザインのパターンとして、コンテナの上下端を CSS の mask-image でフェードアウトさせ、「上下にコンテンツが続いている感」を表現しようとしました。

/* 静的マスクの素朴な指定 */
.toc-scroll-area {
mask-image: linear-gradient(
to bottom,
transparent 0%,
black 16px,
black calc(100% - 24px),
transparent 100%
);
}

これで上下がふわっと消えて、スクロールできる雰囲気は出ました。しかし、実際に色々な記事で動かしてみると、地味に致命的な2つの落とし穴 にぶち当たることになりました。

  1. 短い記事での文字薄れ:
    見出しが3〜4個しかない短い記事では、目次枠内にすべて収まるためスクロールする必要がありません。それなのに常時静的マスクがかかっているせいで、少なくとも先頭は、ウィンドウサイズ次第では末尾の見出しの文字も、勝手にフェードアウトして薄くなってしまい、非常に読みにくい。
  2. 端でのハイライト欠け:
    一番上の見出しを読んでいるとき(上スクロールができない最上部)、上端のマスクが常に出ているせいで、せっかくの ::before ハイライトの上半分がフェードアウトで削れて半透明になってしまう(一番下の見出しでも同様に下部が削れる)。

スクロールできる長文記事の「中間部分」では綺麗に見えても、端に到達したときや短い記事では破綻してしまうのです。

#解決策: scroll(self) による動的スクロール連動マスク

求めている理想の挙動を整理すると、以下のようになります。

  • 最上部にいるとき: 上はマスクなし(black 0%)、下端のみフェードアウト。
  • スクロール途中にいるとき: 上下両端をフェードアウト。
  • 最下部にいるとき: 上端のみフェード、下はマスクなし(black 100%)。
  • そもそもスクロール不要な短い記事のとき: マスクを一切かけない(none)。

「JavaScriptで scroll イベントを監視してクラスを付け替えるしかないかー?」と一瞬諦めかけましたが、ここでも Scroll-driven Animations の scroll(self) が解決してくれました。

コンテナ自身のスクロール進捗(0% 〜 100%) に連動させて、キーフレームで mask-image のグラデーションを動的に切り替えるアプローチです。

src/styles/articledetail.css
@keyframes adjust-toc-mask {
0% {
/* 最上部:上マスクなし(black 0%)、下端のみフェード */
-webkit-mask-image: linear-gradient(
to bottom,
black 0%,
black calc(100% - 24px),
transparent 100%
);
mask-image: linear-gradient(
to bottom,
black 0%,
black calc(100% - 24px),
transparent 100%
);
}
1%, 99% {
/* スクロール途中:上下両端をフェードアウト */
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0%,
black 16px,
black calc(100% - 24px),
transparent 100%
);
mask-image: linear-gradient(
to bottom,
transparent 0%,
black 16px,
black calc(100% - 24px),
transparent 100%
);
}
100% {
/* 最下部:上端のみフェード、下マスクなし(black 100%) */
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0%,
black 16px,
black 100%
);
mask-image: linear-gradient(
to bottom,
transparent 0%,
black 16px,
black 100%
);
}
}
.toc-scroll-area {
scrollbar-width: none;
-ms-overflow-style: none;
padding-top: 4px;
padding-bottom: 8px;
/* 初期値はマスクなし(スクロール不要時や非対応ブラウザ用) */
mask-image: none;
-webkit-mask-image: none;
/* コンテナ自身のスクロール位置に応じてキーフレームを実行 */
/* ※ビルドツールのミニファイアによる不正なショートハンド結合を防ぐためロングハンドで指定 */
animation-name: adjust-toc-mask;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: scroll(self);
}

#なぜこれで動くのか

この指定を組み込んだところ、綺麗にすべての問題が解消しました。なぜこれで動くのか、仕様上のポイントがいくつかあります。

  • W3Cの Inactive 仕様による短い記事の自動解決:
    W3CのScroll-driven Animations仕様では、「スクロール可能なオーバーフローが存在しない要素では、スクロールタイムラインは不活性(Inactive)になる」 と明確に定義されています。
    見出しが数個しかなくスクロールが発生しない短い記事では、scroll(self) タイムライン自体が不活性になりアニメーションが発火しません。その結果、初期値である mask-image: none; がそのまま維持され、文字が勝手に薄くなる現象が自然に防がれます。
  • 端でのハイライト保護:
    キーフレームの 0% では上端が black 0%、100% では下端が black 100%(完全に不透明)になるよう定義しているため、最上部・最下部に到達した際、端にある見出しのハイライトがマスクで削れることなく綺麗に角丸で描画されます。
  • 安全なプログレッシブエンハンスメント:
    Firefox などの非対応環境では animation-timeline が単に解釈されず無視されるため、初期値の mask-image: none; が適用されます。フェードはかかりませんが、文字が見えなくなったり崩れたりする心配がありません。

スクロールバーを消しつつ、スクロール可能なときだけ端がふわっとフェードし、端に到達した要素のハイライトは絶対に削らない。この繊細なUI制御が、CSSのキーフレームと scroll(self) だけで完結したのはかなり気持ちの良い体験でした。

#まとめ

CSS Scroll-driven Animations を使って、完全No-JSの目次を作ってみた記録をまとめました。

  • timeline-scope: 本文と目次のようにDOMツリーが離れていても、共通の親要素で事前宣言することでタイムラインを共有できる。
  • 自作rehypeプラグイン: Markdown内の見出しを自動で <section> ラップし、ビルドパイプラインでタイムラインを全自動バインド。
  • 疑似要素 opacity によるコンポジタ最適化: background-color のスクロール駆動アニメーションで発生するChromiumの既知バグ(Issue 562847604)を回避し、ラスタライズ不要なコンポジタ処理で往復スクロールの安定性を確保。
  • scroll(self) と動的マスク: W3Cの Inactive 仕様を活かし、短い記事ではマスクを無効化しつつ、端到達時のハイライト欠けを防ぐアフォーダンスUIを実現。

「目次のハイライトくらいCSSだけでサクッとできるだろう」と軽い気持ちで始めたら、DOMスコープの引き上げからブラウザのコンポジタバグ、果てはW3Cのタイムライン不活性仕様まで踏み込むことになり、地味ながらかなり学びの多い実装となりました。

当ブログのPC画面(画面幅1280px以上)で実際にこの目次が動作しています。PCからご覧の方は、ぜひ右側のサイドバー目次を眺めながらスクロールしてみていただければ幸いです。( Firefox などの非対応ブラウザ向けには最小限のフォールバック用スクリプトを添えていますが、 Chrome 等では完全に 0 KB JS で動いています!)
Dev Toolを使ってJSを無効化しても動くのを確認できるはずです。

今後もWeb標準の面白い機能があれば、どんどんブログ上で実験していきたいと思います。

#参考リンク

#あとがき: 目次枠自体の自動スクロール見送り経緯

今回の実装を進める中で、もう一つ検討していた機能がありました。それは、「長文記事で本文をスクロールしたとき、目次枠自体もアクティブな項目が見える位置まで自動スクロール(auto scroll-into-view)させたい」という点です。

JavaScriptを使えば、アクティブになった要素に対して element.scrollIntoView({ block: 'nearest' }) を呼ぶだけで簡単に実現できます。

しかし、現在のCSS仕様をどれだけ漁っても、「ある要素のアニメーション進行度に応じて、別の要素のスクロール位置(scrollTop)を操作する機能」は存在しません。

transform: translateY(...) などを使って目次リストの中身全体を無理やり引き上げるようなハックも一瞬頭をよぎりましたが、そんなことをすればコンテナのスクロール位置とズレて手動スクロール操作と競合し、間違いなく使い勝手を損ないます。

「今回はCSSだけでどこまでいけるか」という挑戦でもあったため、無理なバッドノウハウで自動スクロールをねじ込むのは見送ることにしました。その代わり、前述の scroll(self) による動的マスクによって、「スクロールバーを消しながらも手動スクロールのアフォーダンスを保ち、端のアクティブ表示を邪魔しないUI」に仕上がったので、実用上はこれで十分快適だなという結論に至りました。